From 988dbc8149db2f780687118af813915a48c1f5ca Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 9 Oct 2023 15:31:52 +0600 Subject: [PATCH 01/83] Add new browser import sources --- DuckDuckGo.xcodeproj/project.pbxproj | 46 ++++++++++- DuckDuckGo/Common/Localizables/UserText.swift | 2 +- .../Bookmarks/HTML/BookmarkHTMLReader.swift | 7 ++ DuckDuckGo/DataImport/ChromePreferences.swift | 8 +- DuckDuckGo/DataImport/DataImport.swift | 79 ++++++++++++------- .../DataImport/Logins/CSV/CSVImporter.swift | 3 +- .../Chromium/ChromiumDataImporter.swift | 14 +++- .../Logins/Chromium/CoccocDataImporter.swift | 41 ++++++++++ .../Logins/Chromium/OperaDataImporter.swift | 41 ++++++++++ .../Logins/Chromium/VivaldiDataImporter.swift | 41 ++++++++++ .../Logins/Chromium/YandexDataImporter.swift | 41 ++++++++++ .../Logins/Firefox/FirefoxDataImporter.swift | 2 +- DuckDuckGo/DataImport/ThirdPartyBrowser.swift | 45 ++++++++++- .../BrowserImportMoreInfoViewController.swift | 4 +- .../View/BrowserImportViewController.swift | 5 +- .../View/DataImportViewController.swift | 19 ++++- .../View/FileImportViewController.swift | 10 +-- DuckDuckGo/DuckDuckGoAppStore.entitlements | 8 ++ DuckDuckGo/Statistics/PixelArguments.swift | 7 ++ .../DataImport/BrowserProfileTests.swift | 6 +- 20 files changed, 371 insertions(+), 58 deletions(-) create mode 100644 DuckDuckGo/DataImport/Logins/Chromium/CoccocDataImporter.swift create mode 100644 DuckDuckGo/DataImport/Logins/Chromium/OperaDataImporter.swift create mode 100644 DuckDuckGo/DataImport/Logins/Chromium/VivaldiDataImporter.swift create mode 100644 DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index fd50c1d44d..452866361a 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -3520,6 +3520,18 @@ B6C2C9EF276081AB005B7F0A /* DeallocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C9EE276081AB005B7F0A /* DeallocationTests.swift */; }; B6C2C9F62760B659005B7F0A /* TestDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C9F42760B659005B7F0A /* TestDataModel.xcdatamodeld */; }; B6C416A7294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C416A6294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift */; }; + B6C8CA9D2AD010CF0060E1CD /* OperaDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CA9C2AD010CF0060E1CD /* OperaDataImporter.swift */; }; + B6C8CA9E2AD010CF0060E1CD /* OperaDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CA9C2AD010CF0060E1CD /* OperaDataImporter.swift */; }; + B6C8CA9F2AD010CF0060E1CD /* OperaDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CA9C2AD010CF0060E1CD /* OperaDataImporter.swift */; }; + B6C8CAA02AD010CF0060E1CD /* OperaDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CA9C2AD010CF0060E1CD /* OperaDataImporter.swift */; }; + B6C8CAA22AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA12AD010D70060E1CD /* VivaldiDataImporter.swift */; }; + B6C8CAA32AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA12AD010D70060E1CD /* VivaldiDataImporter.swift */; }; + B6C8CAA42AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA12AD010D70060E1CD /* VivaldiDataImporter.swift */; }; + B6C8CAA52AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA12AD010D70060E1CD /* VivaldiDataImporter.swift */; }; + B6C8CAA72AD010DD0060E1CD /* YandexDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */; }; + B6C8CAA82AD010DD0060E1CD /* YandexDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */; }; + B6C8CAA92AD010DD0060E1CD /* YandexDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */; }; + B6C8CAAA2AD010DD0060E1CD /* YandexDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */; }; B6CA4824298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */; }; B6CA4825298CE4B70067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */; }; B6D574B429472253008ED1B6 /* FBProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */; }; @@ -3544,6 +3556,10 @@ B6E1491129A5C30A00AAFBE8 /* FBProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */; }; B6E319382953446000DD3BCF /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E319372953446000DD3BCF /* Assertions.swift */; }; B6E61EE3263AC0C8004E11AB /* FileManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E61EE2263AC0C8004E11AB /* FileManagerExtension.swift */; }; + B6E951412AD3B3370036C46F /* CoccocDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E951402AD3B3370036C46F /* CoccocDataImporter.swift */; }; + B6E951422AD3B3370036C46F /* CoccocDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E951402AD3B3370036C46F /* CoccocDataImporter.swift */; }; + B6E951432AD3B3370036C46F /* CoccocDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E951402AD3B3370036C46F /* CoccocDataImporter.swift */; }; + B6E951442AD3B3370036C46F /* CoccocDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E951402AD3B3370036C46F /* CoccocDataImporter.swift */; }; B6EC37DE29B5D05A001ACE79 /* DownloadsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EC37DD29B5D05A001ACE79 /* DownloadsIntegrationTests.swift */; }; B6EC37DF29B5D05A001ACE79 /* DownloadsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EC37DD29B5D05A001ACE79 /* DownloadsIntegrationTests.swift */; }; B6EC37EB29B5DA2A001ACE79 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EC37EA29B5DA2A001ACE79 /* main.swift */; }; @@ -4821,6 +4837,9 @@ B6C2C9EE276081AB005B7F0A /* DeallocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeallocationTests.swift; sourceTree = ""; }; B6C2C9F52760B659005B7F0A /* Permissions.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Permissions.xcdatamodel; sourceTree = ""; }; B6C416A6294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerTabExtension.swift; sourceTree = ""; }; + B6C8CA9C2AD010CF0060E1CD /* OperaDataImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperaDataImporter.swift; sourceTree = ""; }; + B6C8CAA12AD010D70060E1CD /* VivaldiDataImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VivaldiDataImporter.swift; sourceTree = ""; }; + B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YandexDataImporter.swift; sourceTree = ""; }; B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdClickAttributionTabExtensionTests.swift; sourceTree = ""; }; B6CA482F298D04690067ECCE /* MockPrivacyConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPrivacyConfiguration.swift; sourceTree = ""; }; B6D574B12947224C008ED1B6 /* ContentBlockingTabExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentBlockingTabExtension.swift; sourceTree = ""; }; @@ -4839,6 +4858,7 @@ B6DB3CFA26A17CB800D459B7 /* PermissionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModel.swift; sourceTree = ""; }; B6E319372953446000DD3BCF /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = ""; }; B6E61EE2263AC0C8004E11AB /* FileManagerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExtension.swift; sourceTree = ""; }; + B6E951402AD3B3370036C46F /* CoccocDataImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoccocDataImporter.swift; sourceTree = ""; }; B6EC37DD29B5D05A001ACE79 /* DownloadsIntegrationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadsIntegrationTests.swift; sourceTree = ""; }; B6EC37E829B5DA2A001ACE79 /* tests-server */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "tests-server"; sourceTree = BUILT_PRODUCTS_DIR; }; B6EC37EA29B5DA2A001ACE79 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; @@ -5872,12 +5892,16 @@ 4B59023726B35F3600489384 /* Chromium */ = { isa = PBXGroup; children = ( + 4B59023C26B35F3600489384 /* BraveDataImporter.swift */, 4B59023826B35F3600489384 /* ChromeDataImporter.swift */, 4B59023926B35F3600489384 /* ChromiumLoginReader.swift */, 4BE53373286E39F10019DBFD /* ChromiumKeychainPrompt.swift */, 4B59023B26B35F3600489384 /* ChromiumDataImporter.swift */, - 4B59023C26B35F3600489384 /* BraveDataImporter.swift */, + B6E951402AD3B3370036C46F /* CoccocDataImporter.swift */, 4B8AC93226B3B06300879451 /* EdgeDataImporter.swift */, + B6C8CA9C2AD010CF0060E1CD /* OperaDataImporter.swift */, + B6C8CAA12AD010D70060E1CD /* VivaldiDataImporter.swift */, + B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */, ); path = Chromium; sourceTree = ""; @@ -6095,12 +6119,12 @@ 4B8AC93726B489C500879451 /* Firefox */ = { isa = PBXGroup; children = ( + 4B8AC93A26B48ADF00879451 /* ASN1Parser.swift */, + B696AFFA2AC5924800C93203 /* FileLineError.swift */, 4B8AC93826B48A5100879451 /* FirefoxLoginReader.swift */, 4B29759628281F0900187C4E /* FirefoxEncryptionKeyReader.swift */, 3701C9CD29BD040900305B15 /* FirefoxBerkeleyDatabaseReader.swift */, 4B5FF67726B602B100D42879 /* FirefoxDataImporter.swift */, - 4B8AC93A26B48ADF00879451 /* ASN1Parser.swift */, - B696AFFA2AC5924800C93203 /* FileLineError.swift */, ); path = Firefox; sourceTree = ""; @@ -10021,6 +10045,7 @@ 1D76760E2A9CE4F000DA0BD7 /* SupportedOsChecker.swift in Sources */, 3192A0102A4C4CFF0084EA89 /* BWNotRespondingAlert.swift in Sources */, 3192A0112A4C4CFF0084EA89 /* DebugUserScript.swift in Sources */, + B6C8CA9F2AD010CF0060E1CD /* OperaDataImporter.swift in Sources */, 3192A0122A4C4CFF0084EA89 /* RecentlyClosedTab.swift in Sources */, 3192A0132A4C4CFF0084EA89 /* PDFSearchTextMenuItemHandler.swift in Sources */, 3192A0142A4C4CFF0084EA89 /* DataImportViewController.swift in Sources */, @@ -10149,6 +10174,7 @@ 3192A0862A4C4CFF0084EA89 /* SharingMenu.swift in Sources */, 3192A0872A4C4CFF0084EA89 /* GrammarFeaturesManager.swift in Sources */, 3192A0882A4C4CFF0084EA89 /* WKMenuItemIdentifier.swift in Sources */, + B6C8CAA42AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */, 3192A0892A4C4CFF0084EA89 /* SafariFaviconsReader.swift in Sources */, 3192A08A2A4C4CFF0084EA89 /* NSScreenExtension.swift in Sources */, 3192A08B2A4C4CFF0084EA89 /* NSBezierPathExtension.swift in Sources */, @@ -10384,6 +10410,7 @@ 3192A1622A4C4CFF0084EA89 /* MenuItemSelectors.swift in Sources */, 3192A1632A4C4CFF0084EA89 /* FaviconView.swift in Sources */, 3192A1642A4C4CFF0084EA89 /* OnboardingFlow.swift in Sources */, + B6C8CAA92AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, 3192A1652A4C4CFF0084EA89 /* PasswordManagementLoginModel.swift in Sources */, BB5CB0A12A7AD59D00B312D1 /* NetworkProtectionDebugUtilities.swift in Sources */, 3192A1662A4C4CFF0084EA89 /* TabViewModel.swift in Sources */, @@ -10497,6 +10524,7 @@ 3192A1C52A4C4CFF0084EA89 /* BookmarkViewModel.swift in Sources */, 3192A1C62A4C4CFF0084EA89 /* DaxSpeech.swift in Sources */, 3192A1C72A4C4CFF0084EA89 /* DuckPlayerSchemeHandler.swift in Sources */, + B6E951432AD3B3370036C46F /* CoccocDataImporter.swift in Sources */, 3192A1C82A4C4CFF0084EA89 /* FirePopoverViewModel.swift in Sources */, 3192A1C92A4C4CFF0084EA89 /* BWCommand.swift in Sources */, 3192A1CA2A4C4CFF0084EA89 /* NSColorExtension.swift in Sources */, @@ -10696,6 +10724,7 @@ 1D36E659298AA3BA00AA485D /* InternalUserDeciderStore.swift in Sources */, 3706FEBC293F6EFF00E42796 /* BWResponse.swift in Sources */, 3706FAF4293F65D500E42796 /* SafariBookmarksReader.swift in Sources */, + B6C8CA9E2AD010CF0060E1CD /* OperaDataImporter.swift in Sources */, 3706FAF5293F65D500E42796 /* SafariVersionReader.swift in Sources */, 3706FAF6293F65D500E42796 /* LoginFaviconView.swift in Sources */, 3706FEC0293F6EFF00E42796 /* BWRequest.swift in Sources */, @@ -10920,6 +10949,7 @@ 3706FBA7293F65D500E42796 /* EncryptedValueTransformer.swift in Sources */, 3706FBA8293F65D500E42796 /* PasteboardBookmark.swift in Sources */, 3706FBA9293F65D500E42796 /* PinnedTabsManager.swift in Sources */, + B6C8CAA32AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */, B66260E829ACD0C900E9E3EE /* DuckPlayerTabExtension.swift in Sources */, 3706FBAA293F65D500E42796 /* HoverUserScript.swift in Sources */, 3706FBAC293F65D500E42796 /* MainMenuActions.swift in Sources */, @@ -11039,6 +11069,7 @@ 4B6785402AA7C726008A5004 /* DailyPixel.swift in Sources */, 3706FC08293F65D500E42796 /* CoreDataBookmarkImporter.swift in Sources */, 3706FC09293F65D500E42796 /* SuggestionViewModel.swift in Sources */, + B6E951422AD3B3370036C46F /* CoccocDataImporter.swift in Sources */, 3706FC0A293F65D500E42796 /* BookmarkManagedObject.swift in Sources */, 3706FC0B293F65D500E42796 /* CSVLoginExporter.swift in Sources */, 37197EAC294244D600394917 /* FutureExtension.swift in Sources */, @@ -11201,6 +11232,7 @@ 3706FC97293F65D500E42796 /* BookmarksOutlineView.swift in Sources */, 3706FC98293F65D500E42796 /* CountryList.swift in Sources */, 3706FC99293F65D500E42796 /* PreferencesSection.swift in Sources */, + B6C8CAA82AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, B655124929A79465009BFE1C /* NavigationActionExtension.swift in Sources */, 3706FC9A293F65D500E42796 /* AutoconsentManagement.swift in Sources */, 3706FC9C293F65D500E42796 /* BookmarkStore.swift in Sources */, @@ -11690,6 +11722,7 @@ 4B9579772AC7AE700062CA31 /* NSPointExtension.swift in Sources */, 4B9579782AC7AE700062CA31 /* WindowsManager.swift in Sources */, 4B9579792AC7AE700062CA31 /* BWRequest.swift in Sources */, + B6C8CAA52AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */, 4B95797A2AC7AE700062CA31 /* WKWebViewConfigurationExtensions.swift in Sources */, 4B95797B2AC7AE700062CA31 /* HomePageDefaultBrowserModel.swift in Sources */, 4B95797C2AC7AE700062CA31 /* CrashReporter.swift in Sources */, @@ -11835,6 +11868,7 @@ 4B957A082AC7AE700062CA31 /* LocalUnprotectedDomains.swift in Sources */, 4B957A092AC7AE700062CA31 /* InternalUserDeciderStore.swift in Sources */, 4B957A0A2AC7AE700062CA31 /* NewWindowPolicy.swift in Sources */, + B6C8CAA02AD010CF0060E1CD /* OperaDataImporter.swift in Sources */, 4B957A0B2AC7AE700062CA31 /* NavigationBarBadgeAnimator.swift in Sources */, 4B957A0C2AC7AE700062CA31 /* NSTextViewExtension.swift in Sources */, 4B957A0D2AC7AE700062CA31 /* FutureExtension.swift in Sources */, @@ -12158,6 +12192,7 @@ 4B957B482AC7AE700062CA31 /* MouseOverButton.swift in Sources */, 4B957B492AC7AE700062CA31 /* FireInfoViewController.swift in Sources */, 4B957B4A2AC7AE700062CA31 /* LoginItem+NetworkProtection.swift in Sources */, + B6C8CAAA2AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, 4B957B4B2AC7AE700062CA31 /* PermissionButton.swift in Sources */, 4B957B4C2AC7AE700062CA31 /* MoreOptionsMenu.swift in Sources */, 4B957B4D2AC7AE700062CA31 /* PermissionAuthorizationViewController.swift in Sources */, @@ -12235,6 +12270,7 @@ 4B957B942AC7AE700062CA31 /* BWCommand.swift in Sources */, 4B957B952AC7AE700062CA31 /* NSColorExtension.swift in Sources */, 4B957B962AC7AE700062CA31 /* Stored.swift in Sources */, + B6E951442AD3B3370036C46F /* CoccocDataImporter.swift in Sources */, 4B957B972AC7AE700062CA31 /* AddressBarButtonsViewController.swift in Sources */, 4B957B982AC7AE700062CA31 /* BWError.swift in Sources */, 4B957B992AC7AE700062CA31 /* ChromeDataImporter.swift in Sources */, @@ -12384,6 +12420,7 @@ 37445F992A1566420029F789 /* SyncDataProviders.swift in Sources */, 4B9292A426670D2A00AD2C21 /* PasteboardWriting.swift in Sources */, 4B92928D26670D1700AD2C21 /* BookmarkOutlineViewCell.swift in Sources */, + B6C8CA9D2AD010CF0060E1CD /* OperaDataImporter.swift in Sources */, B604085C274B8FBA00680351 /* UnprotectedDomains.xcdatamodeld in Sources */, 4BB88B5025B7BA2B006F6B06 /* TabInstrumentation.swift in Sources */, 4B59024326B35F7C00489384 /* BrowserImportViewController.swift in Sources */, @@ -12482,6 +12519,7 @@ 4BCF15DD2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift in Sources */, 31B4AF532901A4F20013585E /* NSEventExtension.swift in Sources */, 85707F26276A335700DC0649 /* Onboarding.swift in Sources */, + B6E951412AD3B3370036C46F /* CoccocDataImporter.swift in Sources */, B68C92C1274E3EF4002AC6B0 /* PopUpWindow.swift in Sources */, AA5FA6A0275F948900DCE9C9 /* Favicons.xcdatamodeld in Sources */, B684592225C93BE000DC17B6 /* Publisher.asVoid.swift in Sources */, @@ -12785,6 +12823,7 @@ EEC111E6294D06290086524F /* JSAlertViewModel.swift in Sources */, 4BE5336C286912D40019DBFD /* BookmarksBarCollectionViewItem.swift in Sources */, B6C0B23926E742610031CB7F /* FileDownloadError.swift in Sources */, + B6C8CAA22AD010D70060E1CD /* VivaldiDataImporter.swift in Sources */, 85589EA027BFE60E0038AD11 /* MoreOrLessView.swift in Sources */, B6A9E47026146A250067D1B9 /* DateExtension.swift in Sources */, AAE7527A263B046100B973F8 /* History.xcdatamodeld in Sources */, @@ -12793,6 +12832,7 @@ 4BB99D0126FE191E001E4761 /* ChromiumBookmarksReader.swift in Sources */, B6C0B23426E71BCD0031CB7F /* Downloads.xcdatamodeld in Sources */, AAE8B110258A456C00E81239 /* TabPreviewViewController.swift in Sources */, + B6C8CAA72AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, 37CC53EC27E8A4D10028713D /* PreferencesPrivacyView.swift in Sources */, 4B0135CE2729F1AA00D54834 /* NSPasteboardExtension.swift in Sources */, 85707F31276A7DCA00DC0649 /* OnboardingViewModel.swift in Sources */, diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index ba06cb120f..ffae74187b 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -390,7 +390,7 @@ struct UserText { static let csvImportDescription = NSLocalizedString("import.logins.csv.description", value: "The CSV importer will try to match column headers to their position.\nIf there is no header, it supports two formats:\n\n1. URL, Username, Password\n2. Title, URL, Username, Password", comment: "Description text for the CSV importer") static let importLoginsSelectCSVFile = NSLocalizedString("import.logins.select-csv-file", value: "Select CSV File…", comment: "Button text for selecting a CSV file") - static let importLoginsSelectSafariCSVFile = NSLocalizedString("import.logins.select-safari-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a Safari CSV file") + static let importLoginsSelectBrowserCSVFile = NSLocalizedString("import.logins.select-browser-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a browser CSV file") static let importLoginsSelect1PasswordCSVFile = NSLocalizedString("import.logins.select-1password-csv-file", value: "Select 1Password CSV File…", comment: "Button text for selecting a 1Password CSV file") static let importLoginsSelectLastPassCSVFile = NSLocalizedString("import.logins.select-lastpass-csv-file", value: "Select LastPass CSV File…", comment: "Button text for selecting a LastPass CSV file") diff --git a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift index 7ab2b8e5a8..5faf8c6fe5 100644 --- a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift @@ -284,8 +284,15 @@ private extension BookmarkImportSource { case .thirdPartyBrowser(.brave), .thirdPartyBrowser(.chrome), + .thirdPartyBrowser(.chromium), + .thirdPartyBrowser(.coccoc), .thirdPartyBrowser(.edge), .thirdPartyBrowser(.firefox), + .thirdPartyBrowser(.opera), + .thirdPartyBrowser(.operaGX), + .thirdPartyBrowser(.tor), + .thirdPartyBrowser(.vivaldi), + .thirdPartyBrowser(.yandex), .thirdPartyBrowser(.onePassword8), .thirdPartyBrowser(.onePassword7), .thirdPartyBrowser(.lastPass), diff --git a/DuckDuckGo/DataImport/ChromePreferences.swift b/DuckDuckGo/DataImport/ChromePreferences.swift index e24be4699a..b3b5163c90 100644 --- a/DuckDuckGo/DataImport/ChromePreferences.swift +++ b/DuckDuckGo/DataImport/ChromePreferences.swift @@ -18,14 +18,14 @@ import AppKit -struct ChromePreferences: Decodable { +struct ChromiumPreferences: Decodable { struct AccountInfo: Decodable { let email: String? let fullName: String? } struct Profile: Decodable { - let name: String + let name: String? let createdByVersion: String? } @@ -33,13 +33,13 @@ struct ChromePreferences: Decodable { let profile: Profile init(from data: Data) throws { - var decoder = JSONDecoder() + let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase self = try decoder.decode(Self.self, from: data) } - var profileName: String { + var profileName: String? { for account in accountInfo ?? [] { switch (account.fullName, account.email) { case (.some(let fullName), .some(let email)): diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index f14e380139..7407103463 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -22,13 +22,19 @@ import SecureStorage enum DataImport { enum Source: CaseIterable, Equatable { - case brave case chrome + case chromium + case coccoc case edge case firefox + case opera + case operaGX case safari case safariTechnologyPreview + case tor + case vivaldi + case yandex case onePassword8 case onePassword7 case lastPass @@ -43,14 +49,28 @@ enum DataImport { return "Brave" case .chrome: return "Chrome" + case .chromium: + return "Chromium" + case .coccoc: + return "Cốc Cốc" case .edge: return "Edge" case .firefox: return "Firefox" + case .opera: + return "Opera" + case .operaGX: + return "OperaGX" case .safari: return "Safari" case .safariTechnologyPreview: return "Safari Technology Preview" + case .tor: + return "Tor Browser" + case .vivaldi: + return "Vivaldi" + case .yandex: + return "Yandex" case .lastPass: return "LastPass" case .onePassword7: @@ -77,7 +97,7 @@ enum DataImport { case .csv, .onePassword8, .onePassword7, .lastPass, .bookmarksHTML: // Users can always import from exported files return true - case .brave, .chrome, .edge, .firefox, .safari, .safariTechnologyPreview: + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: // Users can't import from browsers unless they're installed return false } @@ -111,6 +131,13 @@ enum DataImport { } struct BrowserProfileList { + + enum Constants { + static let chromiumDefaultProfileName = "Default" + static let chromiumProfilePrefix = "Profile " + static let firefoxDefaultProfileName = "default-release" + } + let browser: ThirdPartyBrowser let profiles: [BrowserProfile] @@ -122,24 +149,24 @@ enum DataImport { self.browser = browser switch browser { - case .brave, .chrome, .edge: + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: // Chromium profiles are either named "Default", or a series of incrementing profile names, i.e. "Profile 1", "Profile 2", etc. let potentialProfiles = profileURLs.map({ - BrowserProfile.for(browser: browser, profileURL: $0) + BrowserProfile(browser: browser, profileURL: $0) }) let filteredProfiles = potentialProfiles.filter { - $0.hasNonDefaultProfileName || - $0.profileName == "Default" || - $0.profileName.hasPrefix("Profile ") + $0.chromiumPreferences != nil + || $0.profileName == Constants.chromiumDefaultProfileName + || $0.profileName.hasPrefix(Constants.chromiumProfilePrefix) } let sortedProfiles = filteredProfiles.sorted() self.profiles = sortedProfiles - case .firefox, .safari, .safariTechnologyPreview: + case .firefox, .safari, .safariTechnologyPreview, .tor: self.profiles = profileURLs.map { - BrowserProfile.for(browser: browser, profileURL: $0) + BrowserProfile(browser: browser, profileURL: $0) }.sorted() case .lastPass, .onePassword7, .onePassword8: self.profiles = [] @@ -152,10 +179,10 @@ enum DataImport { var defaultProfile: BrowserProfile? { switch browser { - case .brave, .chrome, .edge: - return profiles.first { $0.profileName == "Default" } ?? profiles.first - case .firefox: - return profiles.first { $0.profileName == "default-release" } ?? profiles.first + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: + return profiles.first { $0.profileName == Constants.chromiumDefaultProfileName } ?? profiles.first + case .firefox, .tor: + return profiles.first { $0.profileName == Constants.firefoxDefaultProfileName } ?? profiles.first case .safari, .safariTechnologyPreview, .lastPass, .onePassword7, .onePassword8: return profiles.first } @@ -171,21 +198,13 @@ enum DataImport { let profileURL: URL var profileName: String { - return detectedChromePreferencesProfileName ?? fallbackProfileName - } - - var hasNonDefaultProfileName: Bool { - return detectedChromePreferencesProfileName != nil + return chromiumPreferences?.profileName ?? fallbackProfileName } private let browser: ThirdPartyBrowser private let fileStore: FileStore private let fallbackProfileName: String - private let detectedChromePreferencesProfileName: String? - - static func `for`(browser: ThirdPartyBrowser, profileURL: URL) -> BrowserProfile { - return BrowserProfile(browser: browser, profileURL: profileURL) - } + let chromiumPreferences: ChromiumPreferences? init(browser: ThirdPartyBrowser, profileURL: URL, fileStore: FileStore = FileManager.default) { self.browser = browser @@ -193,7 +212,7 @@ enum DataImport { self.profileURL = profileURL self.fallbackProfileName = Self.getDefaultProfileName(at: profileURL) - self.detectedChromePreferencesProfileName = Self.getChromeProfileName(at: profileURL, fileStore: fileStore) + self.chromiumPreferences = Self.getChromiumProfilePreferences(at: profileURL, fileStore: fileStore) } var hasBrowserData: Bool { @@ -204,7 +223,7 @@ enum DataImport { let profileDirectoryContentsSet = Set(profileDirectoryContents) switch browser { - case .brave, .chrome, .edge: + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi: let hasChromiumLogins = ChromiumLoginReader.LoginDataFileName.allCases.contains { loginFileName in return profileDirectoryContentsSet.contains(loginFileName.rawValue) } @@ -212,7 +231,7 @@ enum DataImport { let hasChromiumBookmarks = profileDirectoryContentsSet.contains(ChromiumBookmarksReader.Constants.defaultBookmarksFileName) return hasChromiumLogins || hasChromiumBookmarks - case .firefox: + case .firefox, .tor: let hasFirefoxLogins = FirefoxLoginReader.DataFormat.allCases.contains { dataFormat in let (databaseName, loginFileName) = dataFormat.formatFileNames @@ -231,7 +250,7 @@ enum DataImport { return profileURL.lastPathComponent.components(separatedBy: ".").last ?? profileURL.lastPathComponent } - private static func getChromeProfileName(at profileURL: URL, fileStore: FileStore) -> String? { + private static func getChromiumProfilePreferences(at profileURL: URL, fileStore: FileStore) -> ChromiumPreferences? { guard let profileDirectoryContents = try? fileStore.directoryContents(at: profileURL.path) else { return nil } @@ -241,9 +260,9 @@ enum DataImport { } if profileDirectoryContents.contains(Constants.chromiumPreferencesFileName), - let chromePreferenceData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.chromiumPreferencesFileName)), - let chromePreferences = try? ChromePreferences(from: chromePreferenceData) { - return chromePreferences.profileName + let preferencesData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.chromiumPreferencesFileName)), + let preferences = try? ChromiumPreferences(from: preferencesData) { + return preferences } return nil diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index 9735181030..065a25957f 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -70,7 +70,8 @@ final class CSVImporter: DataImporter { switch source { case .onePassword7, .onePassword8: self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2, maximumIndex: 7) - case .lastPass, .firefox, .edge, .chrome, .brave, .safari, .safariTechnologyPreview, .csv, .bookmarksHTML: + case .lastPass, .firefox, .edge, .chrome, .chromium, .coccoc, .brave, .opera, .operaGX, + .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex, .csv, .bookmarksHTML: return nil } } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift index bff180da0a..e356487226 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift @@ -21,11 +21,11 @@ import Foundation internal class ChromiumDataImporter: DataImporter { var processName: String { - fatalError("Subclasses must provide their own process name") + "Chromium" } var source: DataImport.Source { - fatalError("Subclasses must return a source") + .chromium } private let applicationDataDirectoryURL: URL @@ -40,6 +40,16 @@ internal class ChromiumDataImporter: DataImporter { self.faviconManager = faviconManager } + convenience init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter) { + let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL + let defaultDataURL = applicationSupport.appendingPathComponent("Chromium/Default/") + + self.init(applicationDataDirectoryURL: defaultDataURL, + loginImporter: loginImporter, + bookmarkImporter: bookmarkImporter, + faviconManager: FaviconManager.shared) + } + func importableTypes() -> [DataImport.DataType] { return [.logins, .bookmarks] } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/CoccocDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/CoccocDataImporter.swift new file mode 100644 index 0000000000..c718be040f --- /dev/null +++ b/DuckDuckGo/DataImport/Logins/Chromium/CoccocDataImporter.swift @@ -0,0 +1,41 @@ +// +// CoccocDataImporter.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final class CoccocDataImporter: ChromiumDataImporter { + + override var processName: String { + return "CocCoc" + } + + override var source: DataImport.Source { + return .coccoc + } + + init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter) { + let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL + let defaultDataURL = applicationSupport.appendingPathComponent("Coccoc/Default/") + + super.init(applicationDataDirectoryURL: defaultDataURL, + loginImporter: loginImporter, + bookmarkImporter: bookmarkImporter, + faviconManager: FaviconManager.shared) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/Chromium/OperaDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/OperaDataImporter.swift new file mode 100644 index 0000000000..f426354ba5 --- /dev/null +++ b/DuckDuckGo/DataImport/Logins/Chromium/OperaDataImporter.swift @@ -0,0 +1,41 @@ +// +// OperaDataImporter.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final class OperaDataImporter: ChromiumDataImporter { + + override var processName: String { + return "Opera" + } + + override var source: DataImport.Source { + return .opera + } + + init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter, gx: Bool) { + let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL + let defaultDataURL = applicationSupport.appendingPathComponent("com.operasoftware.Opera\(gx ? "GX" : "")/") + + super.init(applicationDataDirectoryURL: defaultDataURL, + loginImporter: loginImporter, + bookmarkImporter: bookmarkImporter, + faviconManager: FaviconManager.shared) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/Chromium/VivaldiDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/VivaldiDataImporter.swift new file mode 100644 index 0000000000..6ea12d0c56 --- /dev/null +++ b/DuckDuckGo/DataImport/Logins/Chromium/VivaldiDataImporter.swift @@ -0,0 +1,41 @@ +// +// VivaldiDataImporter.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final class VivaldiDataImporter: ChromiumDataImporter { + + override var processName: String { + return "Vivaldi" + } + + override var source: DataImport.Source { + return .vivaldi + } + + init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter) { + let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL + let defaultDataURL = applicationSupport.appendingPathComponent("Vivaldi/Default/") + + super.init(applicationDataDirectoryURL: defaultDataURL, + loginImporter: loginImporter, + bookmarkImporter: bookmarkImporter, + faviconManager: FaviconManager.shared) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift new file mode 100644 index 0000000000..8055fb9e14 --- /dev/null +++ b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift @@ -0,0 +1,41 @@ +// +// YandexDataImporter.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final class YandexDataImporter: ChromiumDataImporter { + + override var processName: String { + return "Yandex" + } + + override var source: DataImport.Source { + return .yandex + } + + init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter) { + let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL + let defaultDataURL = applicationSupport.appendingPathComponent("Yandex/YandexBrowser/Default/") + + super.init(applicationDataDirectoryURL: defaultDataURL, + loginImporter: loginImporter, + bookmarkImporter: bookmarkImporter, + faviconManager: FaviconManager.shared) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift index ceddc3eb5e..c01aef8b1f 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift @@ -19,7 +19,7 @@ import Foundation import SecureStorage -final class FirefoxDataImporter: DataImporter { +internal class FirefoxDataImporter: DataImporter { var primaryPassword: String? diff --git a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift index 6428ed4b75..aa5ee43c85 100644 --- a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift +++ b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift @@ -32,10 +32,17 @@ enum ThirdPartyBrowser: CaseIterable { case brave case chrome + case chromium + case coccoc case edge case firefox + case opera + case operaGX case safari case safariTechnologyPreview + case tor + case vivaldi + case yandex case lastPass case onePassword7 case onePassword8 @@ -49,10 +56,17 @@ enum ThirdPartyBrowser: CaseIterable { switch source { case .brave: return .brave case .chrome: return .chrome + case .chromium: return .chromium + case .coccoc: return .coccoc case .edge: return .edge case .firefox: return .firefox + case .opera: return .opera + case .operaGX: return .operaGX case .safari: return .safari case .safariTechnologyPreview: return .safariTechnologyPreview + case .tor: return .tor + case .vivaldi: return .vivaldi + case .yandex: return .yandex case .lastPass: return .lastPass case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 @@ -76,10 +90,17 @@ enum ThirdPartyBrowser: CaseIterable { switch self { case .brave: return .brave case .chrome: return .chrome + case .chromium: return .chromium + case .coccoc: return .coccoc case .edge: return .edge case .firefox: return .firefox + case .opera: return .opera + case .operaGX: return .operaGX case .safari: return .safari case .safariTechnologyPreview: return .safariTechnologyPreview + case .tor: return .tor + case .vivaldi: return .vivaldi + case .yandex: return .yandex case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 case .lastPass: return .lastPass @@ -120,14 +141,25 @@ enum ThirdPartyBrowser: CaseIterable { private var bundleIdentifiers: BundleIdentifiers { switch self { case .brave: return BundleIdentifiers(productionBundleID: "com.brave.Browser", relatedBundleIDs: ["com.brave.Browser.nightly"]) - case .chrome: return BundleIdentifiers(productionBundleID: "com.google.Chrome", relatedBundleIDs: ["com.google.Chrome.canary"]) + case .chrome: return BundleIdentifiers(productionBundleID: "com.google.Chrome", relatedBundleIDs: [ + "com.google.Chrome.canary", + "com.google.Chrome.beta", + "com.google.Chrome.dev" + ]) + case .chromium: return BundleIdentifiers(productionBundleID: "org.chromium.Chromium", relatedBundleIDs: []) + case .coccoc: return BundleIdentifiers(productionBundleID: "com.coccoc.Coccoc", relatedBundleIDs: []) case .edge: return BundleIdentifiers(productionBundleID: "com.microsoft.edgemac", relatedBundleIDs: []) case .firefox: return BundleIdentifiers(productionBundleID: "org.mozilla.firefox", relatedBundleIDs: [ "org.mozilla.nightly", "org.mozilla.firefoxdeveloperedition" ]) + case .opera: return BundleIdentifiers(productionBundleID: "com.operasoftware.Opera", relatedBundleIDs: []) + case .operaGX: return BundleIdentifiers(productionBundleID: "com.operasoftware.OperaGX", relatedBundleIDs: []) case .safari: return BundleIdentifiers(productionBundleID: "com.apple.safari", relatedBundleIDs: []) case .safariTechnologyPreview: return BundleIdentifiers(productionBundleID: "com.apple.SafariTechnologyPreview", relatedBundleIDs: []) + case .tor: return BundleIdentifiers(productionBundleID: "org.torproject.torbrowser", relatedBundleIDs: []) + case .vivaldi: return BundleIdentifiers(productionBundleID: "com.vivaldi.Vivaldi", relatedBundleIDs: []) + case .yandex: return BundleIdentifiers(productionBundleID: "ru.yandex.desktop.yandex-browser", relatedBundleIDs: []) case .onePassword7: return BundleIdentifiers(productionBundleID: "com.agilebits.onepassword7", relatedBundleIDs: [ "com.agilebits.onepassword", "com.agilebits.onepassword4" @@ -158,11 +190,12 @@ enum ThirdPartyBrowser: CaseIterable { } guard let profilePath = profilesDirectory(applicationSupportURL: applicationSupportURL), - let potentialProfileURLs = try? FileManager.default.contentsOfDirectory(at: profilePath, + var potentialProfileURLs = try? FileManager.default.contentsOfDirectory(at: profilePath, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]).filter(\.hasDirectoryPath) else { return nil } + potentialProfileURLs.append(profilePath) return DataImport.BrowserProfileList(browser: self, profileURLs: potentialProfileURLs) } @@ -179,14 +212,22 @@ enum ThirdPartyBrowser: CaseIterable { } // Returns the URL to the profiles for a given browser. This directory will contain a list of directories, each representing a profile. + // swiftlint:disable:next cyclomatic_complexity private func profilesDirectory(applicationSupportURL: URL) -> URL? { switch self { case .brave: return applicationSupportURL.appendingPathComponent("BraveSoftware/Brave-Browser/") case .chrome: return applicationSupportURL.appendingPathComponent("Google/Chrome/") + case .chromium: return applicationSupportURL.appendingPathComponent("Chromium/") + case .coccoc: return applicationSupportURL.appendingPathComponent("Coccoc/") case .edge: return applicationSupportURL.appendingPathComponent("Microsoft Edge/") case .firefox: return applicationSupportURL.appendingPathComponent("Firefox/Profiles/") + case .opera: return applicationSupportURL.appendingPathComponent("com.operasoftware.Opera/") + case .operaGX: return applicationSupportURL.appendingPathComponent("com.operasoftware.OperaGX/") case .safari: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("Safari/") case .safariTechnologyPreview: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("SafariTechnologyPreview/") + case .tor: return applicationSupportURL.appendingPathComponent("TorBrowser-Data/Browser/") + case .vivaldi: return applicationSupportURL.appendingPathComponent("Vivaldi/") + case .yandex: return applicationSupportURL.appendingPathComponent("Yandex/YandexBrowser/") case .lastPass, .onePassword7, .onePassword8: return nil } } diff --git a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift index 53374dc830..12e8ba2ea1 100644 --- a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift +++ b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift @@ -49,10 +49,10 @@ final class BrowserImportMoreInfoViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() switch source { - case .chrome, .edge, .brave: + case .chrome, .chromium, .coccoc, .edge, .brave, .opera, .operaGX, .vivaldi, .yandex: label.stringValue = UserText.importFromChromiumMoreInfo - case .firefox: + case .firefox, .tor: label.stringValue = UserText.importFromFirefoxMoreInfo case .safari, .safariTechnologyPreview, .csv, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: diff --git a/DuckDuckGo/DataImport/View/BrowserImportViewController.swift b/DuckDuckGo/DataImport/View/BrowserImportViewController.swift index d61c77f52f..bd486bdbe4 100644 --- a/DuckDuckGo/DataImport/View/BrowserImportViewController.swift +++ b/DuckDuckGo/DataImport/View/BrowserImportViewController.swift @@ -113,8 +113,11 @@ final class BrowserImportViewController: NSViewController { passwordsWarningLabel.isHidden = false return } - passwordsWarningLabel.isHidden = safariMajorVersion >= 15 + case .yandex, .tor: + passwordsCheckbox.isHidden = true + bookmarksCheckbox.title = UserText.bookmarkImportBookmarks + passwordsWarningLabel.isHidden = true default: bookmarksCheckbox.title = UserText.bookmarkImportBookmarks passwordsWarningLabel.isHidden = true diff --git a/DuckDuckGo/DataImport/View/DataImportViewController.swift b/DuckDuckGo/DataImport/View/DataImportViewController.swift index 737d725279..5883b4fe28 100644 --- a/DuckDuckGo/DataImport/View/DataImportViewController.swift +++ b/DuckDuckGo/DataImport/View/DataImportViewController.swift @@ -182,7 +182,7 @@ final class DataImportViewController: NSViewController { case .csv, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: self.viewState = ViewState(selectedImportSource: source, interactionState: .unableToImport) - case .chrome, .firefox, .brave, .edge, .safari, .safariTechnologyPreview: + case .chrome, .chromium, .coccoc, .firefox, .brave, .edge, .opera, .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: let interactionState: InteractionState switch (source, loginsSelected) { case (.safari, _), @@ -216,6 +216,7 @@ final class DataImportViewController: NSViewController { } } + // swiftlint:disable:next cyclomatic_complexity private func throwingRefreshDataImporter() throws { let bookmarkImporter = CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared) @@ -224,9 +225,21 @@ final class DataImportViewController: NSViewController { self.dataImporter = try BraveDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) case .chrome: self.dataImporter = try ChromeDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) + case .chromium: + self.dataImporter = try ChromiumDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) + case .coccoc: + self.dataImporter = try CoccocDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) case .edge: self.dataImporter = try EdgeDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .firefox: + case .opera, .operaGX: + self.dataImporter = try OperaDataImporter(loginImporter: secureVaultImporter(), + bookmarkImporter: bookmarkImporter, + gx: viewState.selectedImportSource == .operaGX) + case .vivaldi: + self.dataImporter = try VivaldiDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) + case .yandex: + self.dataImporter = try YandexDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) + case .firefox, .tor: self.dataImporter = try FirefoxDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter, faviconManager: FaviconManager.shared) @@ -321,7 +334,7 @@ final class DataImportViewController: NSViewController { } fallthrough - case .brave, .chrome, .edge, .firefox: + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .tor, .vivaldi, .yandex: if case let .completedImport(summary) = interactionState { return BrowserImportSummaryViewController.create(importSummary: summary) } else if case let .permissionsRequired(types) = interactionState { diff --git a/DuckDuckGo/DataImport/View/FileImportViewController.swift b/DuckDuckGo/DataImport/View/FileImportViewController.swift index 505b8ea2cd..e531df4fa5 100644 --- a/DuckDuckGo/DataImport/View/FileImportViewController.swift +++ b/DuckDuckGo/DataImport/View/FileImportViewController.swift @@ -107,13 +107,13 @@ final class FileImportViewController: NSViewController { private func renderAwaitingFileSelectionState() { switch importSource { - case .safari, .safariTechnologyPreview: + case .safari, .safariTechnologyPreview, .yandex: descriptionLabel.isHidden = true safariInfoView.isHidden = false lastPassInfoView.isHidden = true onePassword7InfoView.isHidden = true onePassword8InfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectSafariCSVFile + selectFileButton.title = UserText.importLoginsSelectBrowserCSVFile case .onePassword7: descriptionLabel.isHidden = true safariInfoView.isHidden = true @@ -136,7 +136,7 @@ final class FileImportViewController: NSViewController { onePassword8InfoView.isHidden = true selectFileButton.title = UserText.importLoginsSelectLastPassCSVFile - case .brave, .chrome, .edge, .firefox: + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .tor, .vivaldi: assertionFailure("CSV Import not supported for \(importSource)") fallthrough case .csv: @@ -208,9 +208,9 @@ final class FileImportViewController: NSViewController { switch importSource { case .bookmarksHTML: delegate?.fileImportViewController(self, didSelectBookmarksFileWithURL: selectedURL) - case .csv, .onePassword8, .onePassword7, .lastPass, .safari, .safariTechnologyPreview: + case .csv, .onePassword8, .onePassword7, .lastPass, .safari, .safariTechnologyPreview, .yandex: delegate?.fileImportViewController(self, didSelectCSVFileWithURL: selectedURL) - case .brave, .chrome, .edge, .firefox: + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .tor, .vivaldi: break } } else { diff --git a/DuckDuckGo/DuckDuckGoAppStore.entitlements b/DuckDuckGo/DuckDuckGoAppStore.entitlements index 340f405420..cfabed1af6 100644 --- a/DuckDuckGo/DuckDuckGoAppStore.entitlements +++ b/DuckDuckGo/DuckDuckGoAppStore.entitlements @@ -26,6 +26,14 @@ /Library/Application Support/BraveSoftware/Brave-Browser/ /Library/Application Support/Firefox/ /Library/Application Support/Microsoft Edge/ + /Library/Application Support/Vivaldi/ + /Library/Application Support/com.operasoftware.Opera/ + /Library/Application Support/com.operasoftware.OperaGX/ + /Library/Application Support/Yandex/YandexBrowser/ + /Library/Application Support/Chromium/ + /Library/Application Support/Coccoc/ + /Library/Application Support/TorBrowser-Data/Browser/ + /Library/Application Support/Arc/User Data/ keychain-access-groups diff --git a/DuckDuckGo/Statistics/PixelArguments.swift b/DuckDuckGo/Statistics/PixelArguments.swift index 0157982970..f6e81355c3 100644 --- a/DuckDuckGo/Statistics/PixelArguments.swift +++ b/DuckDuckGo/Statistics/PixelArguments.swift @@ -129,6 +129,8 @@ extension DataImport.Source: CustomStringConvertible { switch self { case .brave: return "source-brave" case .chrome: return "source-chrome" + case .chromium: return "source-chromium" + case .coccoc: return "source-coccoc" case .csv: return "source-csv" case .lastPass: return "source-lastpass" case .onePassword7: return "source-1password" @@ -138,6 +140,11 @@ extension DataImport.Source: CustomStringConvertible { case .safari: return "source-safari" case .safariTechnologyPreview: return "source-safari-technology-preview" case .bookmarksHTML: return "source-bookmarks-html" + case .opera: return "source-opera" + case .operaGX: return "source-operagx" + case .tor: return "source-tor" + case .vivaldi: return "source-vivaldi" + case .yandex: return "source-yandex" } } } diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index 408d99aff2..1dc15acb7e 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -89,7 +89,7 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "User Name (profile@duck.com)") - XCTAssertTrue(profile.hasNonDefaultProfileName) + XCTAssertNotNil(profile.chromiumPreferences?.profileName) } func testWhenGettingProfileName_AndProfileHasNoDetectedChromiumName_ThenDetectedNameIsUsed() { @@ -111,7 +111,7 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "ChromeProfile") - XCTAssertTrue(profile.hasNonDefaultProfileName) + XCTAssertNotNil(profile.chromiumPreferences?.profileName) } func testWhenGettingProfileName_AndChromiumPreferencesAreDetected_AndProfileNameIsSystemProfile_ThenProfileHasDefaultProfileName() { @@ -132,7 +132,7 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "System Profile") - XCTAssertFalse(profile.hasNonDefaultProfileName) + XCTAssertNil(profile.chromiumPreferences?.profileName) } private func profile(named name: String) -> URL { From 5cdfa77ebc6d939de3b087b54587210ca05df2fa Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 9 Oct 2023 16:52:11 +0600 Subject: [PATCH 02/83] fix tests --- UnitTests/DataImport/ThirdPartyBrowserTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/UnitTests/DataImport/ThirdPartyBrowserTests.swift b/UnitTests/DataImport/ThirdPartyBrowserTests.swift index 469f16e2ab..1ae1558f65 100644 --- a/UnitTests/DataImport/ThirdPartyBrowserTests.swift +++ b/UnitTests/DataImport/ThirdPartyBrowserTests.swift @@ -81,9 +81,9 @@ class ThirdPartyBrowserTests: XCTestCase { return } - XCTAssertEqual(list.profiles.count, 2) + let validProfiles = list.profiles.filter { $0.hasBrowserData } + XCTAssertEqual(validProfiles.count, 2) XCTAssertEqual(list.defaultProfile?.profileName, "default-release") - XCTAssertTrue(list.profiles.allSatisfy { profile in return profile.hasBrowserData }) } func testWhenGettingBrowserProfiles_AndFirefoxProfileOnlyHasBookmarksData_ThenFirefoxProfileIsReturned() throws { @@ -107,9 +107,9 @@ class ThirdPartyBrowserTests: XCTestCase { return } - XCTAssertEqual(list.profiles.count, 1) + let validProfiles = list.profiles.filter { $0.hasBrowserData } + XCTAssertEqual(validProfiles.count, 1) XCTAssertEqual(list.defaultProfile?.profileName, "default-release") - XCTAssertTrue(list.profiles.allSatisfy { profile in return profile.hasBrowserData }) } private func key4DatabaseURL() -> URL { From b1ee94ad2dff0c0f707459f547fb9e3c32a06ad5 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 9 Oct 2023 22:31:15 +0600 Subject: [PATCH 03/83] Bitwarden source, Refactor CSV parser, improve column detection --- DuckDuckGo/Common/Localizables/UserText.swift | 1 + .../Bookmarks/HTML/BookmarkHTMLReader.swift | 1 + DuckDuckGo/DataImport/DataImport.swift | 10 +- .../DataImport/Logins/CSV/CSVImporter.swift | 198 +++++-- .../DataImport/Logins/CSV/CSVParser.swift | 156 +++-- .../Logins/Chromium/ChromiumLoginReader.swift | 6 +- .../Logins/Firefox/FirefoxLoginReader.swift | 2 +- .../DataImport/Logins/LoginImport.swift | 49 +- .../SecureVaultLoginImporter.swift | 2 +- DuckDuckGo/DataImport/ThirdPartyBrowser.swift | 6 +- .../BrowserImportMoreInfoViewController.swift | 2 +- .../DataImport/View/DataImport.storyboard | 533 ++++++++++++++---- .../View/DataImportViewController.swift | 6 +- .../View/FileImportViewController.swift | 20 +- DuckDuckGo/Statistics/PixelArguments.swift | 1 + UnitTests/DataImport/CSVImporterTests.swift | 8 + UnitTests/DataImport/CSVParserTests.swift | 57 +- 17 files changed, 796 insertions(+), 262 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index ba06cb120f..2e4608c042 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -393,6 +393,7 @@ struct UserText { static let importLoginsSelectSafariCSVFile = NSLocalizedString("import.logins.select-safari-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a Safari CSV file") static let importLoginsSelect1PasswordCSVFile = NSLocalizedString("import.logins.select-1password-csv-file", value: "Select 1Password CSV File…", comment: "Button text for selecting a 1Password CSV file") static let importLoginsSelectLastPassCSVFile = NSLocalizedString("import.logins.select-lastpass-csv-file", value: "Select LastPass CSV File…", comment: "Button text for selecting a LastPass CSV file") + static let importLoginsSelectBitwardenCSVFile = NSLocalizedString("import.logins.select-bitwarden-csv-file", value: "Select Bitwarden CSV File…", comment: "Button text for selecting a Bitwarden CSV file") static let importLoginsSelectAnotherFile = NSLocalizedString("import.logins.select-another-file", value: "Select Another CSV File…", comment: "Button text for selecting another file") static let importLoginsFailedToReadCSVFile = NSLocalizedString("import.logins.failed-to-read-file", value: "Failed to get CSV file URL", comment: "Error text when importing a CSV file") diff --git a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift index 7ab2b8e5a8..f4b2df4212 100644 --- a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift @@ -286,6 +286,7 @@ private extension BookmarkImportSource { .thirdPartyBrowser(.chrome), .thirdPartyBrowser(.edge), .thirdPartyBrowser(.firefox), + .thirdPartyBrowser(.bitwarden), .thirdPartyBrowser(.onePassword8), .thirdPartyBrowser(.onePassword7), .thirdPartyBrowser(.lastPass), diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index f14e380139..78ab5dc8d3 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -31,6 +31,7 @@ enum DataImport { case safariTechnologyPreview case onePassword8 case onePassword7 + case bitwarden case lastPass case csv case bookmarksHTML @@ -51,6 +52,8 @@ enum DataImport { return "Safari" case .safariTechnologyPreview: return "Safari Technology Preview" + case .bitwarden: + return "Bitwarden" case .lastPass: return "LastPass" case .onePassword7: @@ -74,7 +77,7 @@ enum DataImport { } switch self { - case .csv, .onePassword8, .onePassword7, .lastPass, .bookmarksHTML: + case .csv, .bitwarden, .onePassword8, .onePassword7, .lastPass, .bookmarksHTML: // Users can always import from exported files return true case .brave, .chrome, .edge, .firefox, .safari, .safariTechnologyPreview: @@ -141,7 +144,7 @@ enum DataImport { self.profiles = profileURLs.map { BrowserProfile.for(browser: browser, profileURL: $0) }.sorted() - case .lastPass, .onePassword7, .onePassword8: + case .bitwarden, .lastPass, .onePassword7, .onePassword8: self.profiles = [] } } @@ -156,7 +159,7 @@ enum DataImport { return profiles.first { $0.profileName == "Default" } ?? profiles.first case .firefox: return profiles.first { $0.profileName == "default-release" } ?? profiles.first - case .safari, .safariTechnologyPreview, .lastPass, .onePassword7, .onePassword8: + case .safari, .safariTechnologyPreview, .bitwarden, .lastPass, .onePassword7, .onePassword8: return profiles.first } } @@ -340,6 +343,7 @@ struct LoginImporterError: DataImportError { let rawValue: Int static let defaultFirefoxProfilePathNotFound = OperationType(rawValue: -1) + static let malformedCSV = OperationType(rawValue: -2) } var type: OperationType { diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index d44974ac0a..e7d071747f 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -16,61 +16,121 @@ // limitations under the License. // +import Common import Foundation final class CSVImporter: DataImporter { struct ColumnPositions { + private enum Regex { + // should end with "login" or "username" + static let username = regex("(?:^|\\b|\\s|_)(?:login|username)$", .caseInsensitive) + // should end with "password" or "pwd" + static let password = regex("(?:^|\\b|\\s|_)(?:password|pwd)$", .caseInsensitive) + // should end with "name" or "title" + static let title = regex("(?:^|\\b|\\s|_)(?:name|title)$", .caseInsensitive) + // should end with "url", "uri" + static let url = regex("(?:^|\\b|\\s|_)(?:url|uri)$", .caseInsensitive) + // should end with "notes" or "note" + static let notes = regex("(?:^|\\b|\\s|_)(?:notes|note)$", .caseInsensitive) + } + + static let rowFormatWithTitle = ColumnPositions(titleIndex: 0, urlIndex: 1, usernameIndex: 2, passwordIndex: 3) + static let rowFormatWithoutTitle = ColumnPositions(titleIndex: nil, urlIndex: 0, usernameIndex: 1, passwordIndex: 2) + let maximumIndex: Int let titleIndex: Int? - let urlIndex: Int + let urlIndex: Int? + let usernameIndex: Int let passwordIndex: Int - init(titleIndex: Int?, urlIndex: Int, usernameIndex: Int, passwordIndex: Int, maximumIndex: Int) { + let notesIndex: Int? + + let isZohoVault: Bool + + init(titleIndex: Int?, urlIndex: Int?, usernameIndex: Int, passwordIndex: Int, notesIndex: Int? = nil, isZohoVault: Bool = false) { self.titleIndex = titleIndex self.urlIndex = urlIndex self.usernameIndex = usernameIndex self.passwordIndex = passwordIndex - self.maximumIndex = maximumIndex + self.notesIndex = notesIndex + self.maximumIndex = max(titleIndex ?? -1, urlIndex ?? -1, usernameIndex, passwordIndex, notesIndex ?? -1) + self.isZohoVault = isZohoVault } - init?(csvValues: [String]) { - guard csvValues.count >= 3 else { return nil } - - var titlePosition: Int? - var urlPosition: Int? - var usernamePosition: Int? - var passwordPosition: Int? - - for (index, value) in csvValues.enumerated() { - switch value.lowercased() { - case "url", "login_uri": urlPosition = index - case "username", "login_username": usernamePosition = index - case "password", "login_password": passwordPosition = index - case "title", "name": titlePosition = index - default: break - } - } + private enum Format { + case general + case zohoGeneral + case zohoVault + } - if let url = urlPosition, let username = usernamePosition, let password = passwordPosition { - self.init(titleIndex: titlePosition, - urlIndex: url, - usernameIndex: username, - passwordIndex: password, - maximumIndex: csvValues.count - 1) + init?(csv: [[String]]) { + guard csv.count > 1, + csv[1].count >= 3 else { return nil } + var headerRow = csv[0] + + var format = Format.general + + let usernameIndex: Int + if let idx = headerRow.firstIndex(where: { value in + Regex.username.matches(in: value, range: value.fullRange).isEmpty == false + }) { + usernameIndex = idx + headerRow[usernameIndex] = "" + + // Zoho + } else if headerRow.first == "Password Name" { + if let idx = csv[1].firstIndex(of: "SecretData") { + format = .zohoVault + usernameIndex = idx + } else if csv[1].count == 7 { + format = .zohoGeneral + usernameIndex = 5 + } else { + return nil + } } else { return nil } + + let passwordIndex: Int + switch format { + case .general: + guard let idx = headerRow + .firstIndex(where: { !Regex.password.matches(in: $0, range: $0.fullRange).isEmpty }) else { return nil } + passwordIndex = idx + headerRow[passwordIndex] = "" + + case .zohoGeneral: + passwordIndex = usernameIndex + 1 + case .zohoVault: + passwordIndex = usernameIndex + } + + let titleIndex = headerRow.firstIndex(where: { !Regex.title.matches(in: $0, range: $0.fullRange).isEmpty }) + titleIndex.map { headerRow[$0] = "" } + + let urlIndex = headerRow.firstIndex(where: { !Regex.url.matches(in: $0, range: $0.fullRange).isEmpty }) + urlIndex.map { headerRow[$0] = "" } + + let notesIndex = headerRow.firstIndex(where: { !Regex.notes.matches(in: $0, range: $0.fullRange).isEmpty }) + + self.init(titleIndex: titleIndex, + urlIndex: urlIndex, + usernameIndex: usernameIndex, + passwordIndex: passwordIndex, + notesIndex: notesIndex, + isZohoVault: format == .zohoVault) } init?(source: DataImport.Source) { switch source { case .onePassword7, .onePassword8: - self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2, maximumIndex: 7) - case .lastPass, .firefox, .edge, .chrome, .brave, .safari, .safariTechnologyPreview, .csv, .bookmarksHTML: + self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2) + case .lastPass, .firefox, .edge, .chrome, .brave, .safari, .safariTechnologyPreview, .csv, .bookmarksHTML, .bitwarden: return nil } } @@ -102,23 +162,19 @@ final class CSVImporter: DataImporter { func totalValidLogins() -> Int { guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8) else { return 0 } - let logins = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) + let logins = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) ?? [] return logins.count } static func extractLogins(from fileContents: String, - defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential] { - let parsed = CSVParser.parse(string: fileContents) + defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential]? { + guard let parsed = try? CSVParser().parse(string: fileContents) else { return nil } - if let possibleHeaderRow = parsed.first, let inferredColumnPositions = ColumnPositions(csvValues: possibleHeaderRow) { - return parsed.dropFirst().compactMap { - ImportedLoginCredential(row: $0, inferredColumnPositions: inferredColumnPositions) - } + if let columnPositions = ColumnPositions(csv: parsed) { + return parsed.dropFirst().compactMap(columnPositions.read) } else { - return parsed.compactMap { - ImportedLoginCredential(row: $0, inferredColumnPositions: defaultColumnPositions) - } + return parsed.compactMap(defaultColumnPositions.read) } } @@ -146,10 +202,12 @@ final class CSVImporter: DataImporter { return } - DispatchQueue.global(qos: .userInitiated).async { - let loginCredentials = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) - + DispatchQueue.global(qos: .userInitiated).async { [defaultColumnPositions] in do { + let loginCredentials = try Self.extractLogins(from: fileContents, + defaultColumnPositions: defaultColumnPositions) ?? { + throw LoginImporterError(source: .csv, error: nil, type: .malformedCSV) + }() let result = try loginImporter.importLogins(loginCredentials) DispatchQueue.main.async { completion(.success(DataImport.Summary(bookmarksResult: nil, loginsResult: .completed(result)))) @@ -171,11 +229,10 @@ extension ImportedLoginCredential { fileprivate var isHeaderRow: Bool { let types: NSTextCheckingResult.CheckingType = [.link] - guard let detector = try? NSDataDetector(types: types.rawValue), self.url.count > 0 else { - return false - } + guard let detector = try? NSDataDetector(types: types.rawValue), + let url, !url.isEmpty else { return false } - if detector.numberOfMatches(in: self.url, options: [], range: self.url.fullRange) > 0 { + if detector.numberOfMatches(in: url, options: [], range: url.fullRange) > 0 { return false } @@ -183,3 +240,54 @@ extension ImportedLoginCredential { } } + +extension CSVImporter.ColumnPositions { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let username: String + let password: String + + if isZohoVault { + // cell contents: + // SecretType:Web Account + // User Name:username + // Password:password + guard let lines = row[safe: usernameIndex]?.components(separatedBy: "\n"), + let usernameLine = lines.firstIndex(where: { $0.hasPrefix("User Name:") }), + let passwordLine = lines.firstIndex(where: { $0.hasPrefix("Password:") }) else { return nil } + + username = lines[usernameLine].dropping(prefix: "User Name:") + password = lines[passwordLine].dropping(prefix: "Password:") + + } else if let user = row[safe: usernameIndex], + let pass = row[safe: passwordIndex] { + + username = user + password = pass + } else { + return nil + } + + return ImportedLoginCredential(title: row[safe: titleIndex ?? -1], + url: row[safe: urlIndex ?? -1], + username: username, + password: password, + notes: row[safe: notesIndex ?? -1]) + } + +} + +extension CSVImporter.ColumnPositions? { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let columnPositions = self ?? [ + .rowFormatWithTitle, + .rowFormatWithoutTitle + ].first(where: { + row.count > $0.maximumIndex + }) + + return columnPositions?.read(row) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift index 7e1eaebcb9..7c872a90d7 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift @@ -18,53 +18,145 @@ import Foundation -final class CSVParser { +struct CSVParser { - static func parse(string: String) -> [[String]] { - return string.parseCSV() + enum ParserError: Error { + case unexpectedCharacterAfterQuote(Character) } -} + func parse(string: String) throws -> [[String]] { + var parser = Parser() + + for character in string { + try parser.accept(character) + } + + parser.flushField() + + return parser.result + } + + private enum State { + case start + case field + case enquotedField + } -private extension String { + private struct Parser { + var delimiter: Character? - func parseCSV() -> [[String]] { var result: [[String]] = [[]] - var currentField = "".unicodeScalars - var inQuotes = false - var hasPrecedingBackslash = false - @inline(__always) func flush() { - result[result.endIndex - 1].append(String(currentField)) - currentField.removeAll() + var state = State.start + var hasPrecedingQuote = false + + var currentField = "" + + @inline(__always) mutating func flushField() { + result[result.endIndex - 1].append(currentField) + currentField = "" + state = .start + hasPrecedingQuote = false + } + + @inline(__always) mutating func nextLine() { + flushField() + result.append([]) + state = .start + hasPrecedingQuote = false } - for character in self.unicodeScalars { - switch (character, inQuotes, hasPrecedingBackslash) { - case (",", false, _): - hasPrecedingBackslash = false - flush() - case ("\n", false, _): - hasPrecedingBackslash = false - flush() - result.append([]) - case ("\\", true, _): - hasPrecedingBackslash = true - case ("\"", _, false): - inQuotes = !inQuotes - case ("\"", _, true): - // The preceding characters was a backslash, so append the quote to the string instead of treating it as a delimiter - hasPrecedingBackslash = false + // swiftlint:disable:next cyclomatic_complexity + mutating func accept(_ character: Character) throws { + switch (state, character.kind(delimiter: delimiter), precedingQuote: hasPrecedingQuote) { + case (_, .unsupported, _): + return // skip control characters + + // expecting field start + case (.start, .quote, _): + state = .enquotedField + case (.start, .delimiter, _): + flushField() + delimiter = character + + case (.start, .whitespace, _): + return // trim leading whitespaces + case (.start, .newline, _): + nextLine() + case (.start, _, _): + state = .field + currentField.append(character) + + // quote in field body is escaped with 2 quotes + case (_, .quote, precedingQuote: false): + hasPrecedingQuote = true + case (_, .quote, precedingQuote: true): currentField.append(character) - default: - hasPrecedingBackslash = false + hasPrecedingQuote = false + + // enquoted field end + case (.enquotedField, .delimiter, precedingQuote: true): + flushField() + delimiter = character + + case (.enquotedField, .newline, precedingQuote: true): + nextLine() + case (.enquotedField, .whitespace, precedingQuote: true): + return // trim whitespaces between fields + + // unbalanced quote + case (_, _, precedingQuote: true): + // only expecting a second quote after a quote in field body + throw ParserError.unexpectedCharacterAfterQuote(character) + + // non-enquoted field end + case (.field, .delimiter, _): + flushField() + delimiter = character + + case (.field, _, _) where character.isNewline: + nextLine() + + case (_, _, precedingQuote: false): currentField.append(character) } } + } + +} + +private extension Character { - flush() + enum Kind { + case quote + case delimiter + case newline + case whitespace + case unsupported + case other + } - return result + func kind(delimiter: Character?) -> Kind { + if self == "\"" { + .quote + } else if self.unicodeScalars.contains(where: { CharacterSet.unsupportedCharacters.contains($0) }) { + .unsupported + } else if CharacterSet.newlines.contains(unicodeScalars.first!) { + .newline + } else if CharacterSet.whitespaces.contains(unicodeScalars.first!) { + .whitespace + } else if self == delimiter || (delimiter == nil && CharacterSet.delimiters.contains(unicodeScalars.first!)) { + .delimiter + } else { + .other + } } } + +private extension CharacterSet { + + static let unsupportedCharacters = CharacterSet.controlCharacters.union(.illegalCharacters).subtracting(.newlines) + static let delimiters = CharacterSet(charactersIn: ",;") + +} diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift index c4379ff6a4..977c7245a0 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift @@ -197,11 +197,7 @@ final class ChromiumLoginReader { return nil } - return ImportedLoginCredential( - url: row.url, - username: row.username, - password: decryptedPassword - ) + return ImportedLoginCredential(url: row.url, username: row.username, password: decryptedPassword) } if result.isEmpty, let lastError { throw lastError diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift index 31f9971995..06fde0a0c9 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift @@ -176,7 +176,7 @@ final class FirefoxLoginReader { currentOperationType = .decryptPassword let decryptedPassword = try decrypt(credential: login.encryptedPassword, key: key) - credentials.append(ImportedLoginCredential(url: login.hostname, username: decryptedUsername, password: decryptedPassword)) + credentials.append(ImportedLoginCredential(url: login.hostname, username: decryptedUsername, password: decryptedPassword, notes: nil)) } catch { lastError = error } diff --git a/DuckDuckGo/DataImport/Logins/LoginImport.swift b/DuckDuckGo/DataImport/Logins/LoginImport.swift index 9c4b569268..f00d28d091 100644 --- a/DuckDuckGo/DataImport/Logins/LoginImport.swift +++ b/DuckDuckGo/DataImport/Logins/LoginImport.swift @@ -21,57 +21,18 @@ import BrowserServicesKit struct ImportedLoginCredential: Equatable { - private enum RowFormatWithTitle: Int { - case title = 0 - case url - case username - case password - } - - private enum RowFormatWithoutTitle: Int { - case url = 0 - case username - case password - } - let title: String? - let url: String + let url: String? let username: String let password: String + let notes: String? - init(title: String? = nil, url: String, username: String, password: String) { + init(title: String? = nil, url: String?, username: String, password: String, notes: String? = nil) { self.title = title - self.url = URL(string: url)?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. + self.url = url.flatMap(URL.init(string:))?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. self.username = username self.password = password - } - - init?(row: [String], inferredColumnPositions: CSVImporter.ColumnPositions? = nil) { - if let inferredPositions = inferredColumnPositions { - guard row.count > inferredPositions.maximumIndex else { return nil } - - var title: String? - - if let titleIndex = inferredPositions.titleIndex { - title = row[titleIndex] - } - - self.init(title: title, - url: row[inferredPositions.urlIndex], - username: row[inferredPositions.usernameIndex], - password: row[inferredPositions.passwordIndex]) - } else if row.count >= 4 { - self.init(title: row[RowFormatWithTitle.title.rawValue], - url: row[RowFormatWithTitle.url.rawValue], - username: row[RowFormatWithTitle.username.rawValue], - password: row[RowFormatWithTitle.password.rawValue]) - } else if row.count >= 3 { - self.init(url: row[RowFormatWithoutTitle.url.rawValue], - username: row[RowFormatWithoutTitle.username.rawValue], - password: row[RowFormatWithoutTitle.password.rawValue]) - } else { - return nil - } + self.notes = notes } } diff --git a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift index aedf2bfb03..2cb2c30df7 100644 --- a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift +++ b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift @@ -41,7 +41,7 @@ final class SecureVaultLoginImporter: LoginImporter { try vault.inDatabaseTransaction { database in for login in logins { let title = login.title - let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url) + let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url, notes: login.notes) let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: login.password.data(using: .utf8)!) let importSummaryValue: String diff --git a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift index 6428ed4b75..5a08118749 100644 --- a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift +++ b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift @@ -36,6 +36,7 @@ enum ThirdPartyBrowser: CaseIterable { case firefox case safari case safariTechnologyPreview + case bitwarden case lastPass case onePassword7 case onePassword8 @@ -53,6 +54,7 @@ enum ThirdPartyBrowser: CaseIterable { case .firefox: return .firefox case .safari: return .safari case .safariTechnologyPreview: return .safariTechnologyPreview + case .bitwarden: return .bitwarden case .lastPass: return .lastPass case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 @@ -80,6 +82,7 @@ enum ThirdPartyBrowser: CaseIterable { case .firefox: return .firefox case .safari: return .safari case .safariTechnologyPreview: return .safariTechnologyPreview + case .bitwarden: return .bitwarden case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 case .lastPass: return .lastPass @@ -128,6 +131,7 @@ enum ThirdPartyBrowser: CaseIterable { ]) case .safari: return BundleIdentifiers(productionBundleID: "com.apple.safari", relatedBundleIDs: []) case .safariTechnologyPreview: return BundleIdentifiers(productionBundleID: "com.apple.SafariTechnologyPreview", relatedBundleIDs: []) + case .bitwarden: return BundleIdentifiers(productionBundleID: "com.bitwarden.desktop", relatedBundleIDs: []) case .onePassword7: return BundleIdentifiers(productionBundleID: "com.agilebits.onepassword7", relatedBundleIDs: [ "com.agilebits.onepassword", "com.agilebits.onepassword4" @@ -187,7 +191,7 @@ enum ThirdPartyBrowser: CaseIterable { case .firefox: return applicationSupportURL.appendingPathComponent("Firefox/Profiles/") case .safari: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("Safari/") case .safariTechnologyPreview: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("SafariTechnologyPreview/") - case .lastPass, .onePassword7, .onePassword8: return nil + case .bitwarden, .lastPass, .onePassword7, .onePassword8: return nil } } diff --git a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift index 53374dc830..f10c1faa57 100644 --- a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift +++ b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift @@ -55,7 +55,7 @@ final class BrowserImportMoreInfoViewController: NSViewController { case .firefox: label.stringValue = UserText.importFromFirefoxMoreInfo - case .safari, .safariTechnologyPreview, .csv, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: + case .safari, .safariTechnologyPreview, .csv, .bitwarden, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: fatalError("Unsupported source for more info") } } diff --git a/DuckDuckGo/DataImport/View/DataImport.storyboard b/DuckDuckGo/DataImport/View/DataImport.storyboard index 2c75c784c1..465e8e3a59 100644 --- a/DuckDuckGo/DataImport/View/DataImport.storyboard +++ b/DuckDuckGo/DataImport/View/DataImport.storyboard @@ -1,7 +1,7 @@ - + - + @@ -16,7 +16,7 @@ - + @@ -26,9 +26,6 @@ - - - @@ -38,6 +35,9 @@ + + + @@ -111,7 +111,7 @@ Gw - + @@ -133,7 +133,7 @@ Gw - + @@ -180,7 +180,7 @@ Gw - + @@ -200,7 +200,7 @@ Gw - + @@ -222,7 +222,7 @@ Gw - + @@ -252,7 +252,7 @@ Gw - + @@ -282,7 +282,7 @@ Gw - + @@ -312,7 +312,7 @@ Gw - + @@ -387,7 +387,7 @@ Gw - + @@ -407,7 +407,7 @@ Gw - + @@ -438,7 +438,7 @@ Gw - + @@ -507,13 +507,13 @@ Gw - + - + - - + + @@ -521,12 +521,12 @@ Gw - + - + @@ -546,7 +546,7 @@ Gw - + @@ -590,7 +590,7 @@ Gw - + @@ -610,7 +610,7 @@ Gw - + @@ -618,16 +618,16 @@ Gw - - + + - - + + @@ -657,7 +657,7 @@ Gw - + @@ -677,7 +677,7 @@ Gw - + @@ -685,7 +685,7 @@ Gw - + @@ -693,7 +693,7 @@ Gw - + @@ -724,7 +724,7 @@ Gw - + @@ -744,7 +744,7 @@ Gw - + @@ -761,7 +761,7 @@ Gw - + @@ -769,7 +769,7 @@ Gw - + @@ -803,7 +803,7 @@ Gw - + @@ -823,7 +823,7 @@ Gw - + @@ -855,7 +855,7 @@ Gw - + @@ -887,9 +887,9 @@ Gw - + - + @@ -912,7 +912,7 @@ Gw - + @@ -932,7 +932,7 @@ Gw - + @@ -959,7 +959,7 @@ Gw - + @@ -979,7 +979,7 @@ Gw - + @@ -987,7 +987,7 @@ Gw - + @@ -1016,7 +1016,7 @@ Gw - + @@ -1036,7 +1036,7 @@ Gw - + @@ -1044,7 +1044,7 @@ Gw - + @@ -1073,7 +1073,7 @@ Gw - + @@ -1093,7 +1093,7 @@ Gw - + @@ -1120,7 +1120,7 @@ Gw - + @@ -1140,7 +1140,7 @@ Gw - + @@ -1174,7 +1174,7 @@ Gw - + @@ -1203,7 +1203,7 @@ Gw - + @@ -1216,7 +1216,7 @@ Gw - + @@ -1237,7 +1237,7 @@ Gw - + @@ -1260,7 +1260,7 @@ Gw - + @@ -1280,7 +1280,7 @@ Gw - + @@ -1307,7 +1307,7 @@ Gw - + @@ -1327,7 +1327,7 @@ Gw - + @@ -1354,7 +1354,7 @@ Gw - + @@ -1374,7 +1374,7 @@ Gw - + @@ -1382,16 +1382,16 @@ Gw - - + + - - + + @@ -1421,7 +1421,7 @@ Gw - + @@ -1441,7 +1441,7 @@ Gw - + @@ -1468,7 +1468,7 @@ Gw - + @@ -1488,7 +1488,7 @@ Gw - + @@ -1515,7 +1515,7 @@ Gw - + @@ -1535,7 +1535,7 @@ Gw - + @@ -1571,7 +1571,7 @@ Gw - + @@ -1602,21 +1602,21 @@ Gw - + - + - + - - + + @@ -1636,8 +1636,8 @@ Gw - - + + @@ -1645,13 +1645,13 @@ Gw - + - + - + @@ -1659,7 +1659,7 @@ Gw - + @@ -1679,7 +1679,7 @@ Gw - + @@ -1698,15 +1698,15 @@ Gw - + - + - + @@ -1726,9 +1726,9 @@ Gw - - - + + + @@ -1753,7 +1753,7 @@ Gw - + @@ -1773,7 +1773,7 @@ Gw - + @@ -1800,7 +1800,7 @@ Gw - + @@ -1820,7 +1820,7 @@ Gw - + @@ -1847,7 +1847,7 @@ Gw - + @@ -1867,8 +1867,8 @@ Gw - - + + @@ -1901,7 +1901,7 @@ Gw - + @@ -1931,8 +1931,321 @@ Gw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -507,13 +507,13 @@ Gw - + - + - - + + @@ -521,12 +521,12 @@ Gw - + - + @@ -546,7 +546,7 @@ Gw - + @@ -590,7 +590,7 @@ Gw - + @@ -610,7 +610,7 @@ Gw - + @@ -618,16 +618,16 @@ Gw - - + + - - + + @@ -657,7 +657,7 @@ Gw - + @@ -677,7 +677,7 @@ Gw - + @@ -685,7 +685,7 @@ Gw - + @@ -693,7 +693,7 @@ Gw - + @@ -724,7 +724,7 @@ Gw - + @@ -744,7 +744,7 @@ Gw - + @@ -761,7 +761,7 @@ Gw - + @@ -769,7 +769,7 @@ Gw - + @@ -803,7 +803,7 @@ Gw - + @@ -823,7 +823,7 @@ Gw - + @@ -855,7 +855,7 @@ Gw - + @@ -886,10 +886,376 @@ Gw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -912,7 +1278,7 @@ Gw - + @@ -932,7 +1298,7 @@ Gw - + @@ -959,7 +1325,7 @@ Gw - + @@ -979,7 +1345,7 @@ Gw - + @@ -987,7 +1353,7 @@ Gw - + @@ -1016,7 +1382,7 @@ Gw - + @@ -1036,7 +1402,7 @@ Gw - + @@ -1044,7 +1410,7 @@ Gw - + @@ -1073,7 +1439,7 @@ Gw - + @@ -1093,7 +1459,7 @@ Gw - + @@ -1120,7 +1486,7 @@ Gw - + @@ -1140,7 +1506,7 @@ Gw - + @@ -1174,7 +1540,7 @@ Gw - + @@ -1216,7 +1582,7 @@ Gw - + @@ -1237,7 +1603,7 @@ Gw - + @@ -1260,7 +1626,7 @@ Gw - + @@ -1280,7 +1646,7 @@ Gw - + @@ -1307,7 +1673,7 @@ Gw - + @@ -1327,7 +1693,7 @@ Gw - + @@ -1354,7 +1720,7 @@ Gw - + @@ -1374,7 +1740,7 @@ Gw - + @@ -1382,16 +1748,16 @@ Gw - - + + - - + + @@ -1421,7 +1787,7 @@ Gw - + @@ -1441,7 +1807,7 @@ Gw - + @@ -1468,7 +1834,7 @@ Gw - + @@ -1488,7 +1854,7 @@ Gw - + @@ -1515,7 +1881,7 @@ Gw - + @@ -1535,7 +1901,7 @@ Gw - + @@ -1571,7 +1937,7 @@ Gw - + @@ -1615,7 +1981,7 @@ Gw - + @@ -1636,7 +2002,7 @@ Gw - + @@ -1659,7 +2025,7 @@ Gw - + @@ -1679,7 +2045,7 @@ Gw - + @@ -1706,7 +2072,7 @@ Gw - + @@ -1726,7 +2092,7 @@ Gw - + @@ -1753,7 +2119,7 @@ Gw - + @@ -1773,7 +2139,7 @@ Gw - + @@ -1800,7 +2166,7 @@ Gw - + @@ -1820,7 +2186,7 @@ Gw - + @@ -1847,7 +2213,7 @@ Gw - + @@ -1867,8 +2233,8 @@ Gw - - + + @@ -1901,7 +2267,7 @@ Gw - + @@ -1932,7 +2298,7 @@ Gw + + + + + + + + - + @@ -449,16 +460,19 @@ Gw + + + @@ -491,6 +505,7 @@ Gw + @@ -507,13 +522,13 @@ Gw - + - + - + @@ -521,7 +536,7 @@ Gw - + @@ -887,7 +902,7 @@ Gw - + @@ -1253,7 +1268,7 @@ Gw - + @@ -1569,7 +1584,7 @@ Gw - + @@ -1968,13 +1983,13 @@ Gw - + - + - + @@ -1982,7 +1997,7 @@ Gw - + @@ -2003,7 +2018,7 @@ Gw - + @@ -2011,13 +2026,13 @@ Gw - + - + - + @@ -2064,10 +2079,10 @@ Gw - + - + @@ -2093,7 +2108,7 @@ Gw - + @@ -2298,10 +2313,10 @@ Gw - + - + @@ -2309,13 +2324,13 @@ Gw - + - + - + @@ -2362,10 +2377,10 @@ Gw - + - + @@ -2391,7 +2406,7 @@ Gw - + @@ -2672,6 +2687,7 @@ Gw + @@ -2682,6 +2698,7 @@ Gw + diff --git a/DuckDuckGo/DataImport/View/DataImportViewController.swift b/DuckDuckGo/DataImport/View/DataImportViewController.swift index 0722417355..7c00e9b9ec 100644 --- a/DuckDuckGo/DataImport/View/DataImportViewController.swift +++ b/DuckDuckGo/DataImport/View/DataImportViewController.swift @@ -183,6 +183,12 @@ final class DataImportViewController: NSViewController { self.viewState = ViewState(selectedImportSource: source, interactionState: .unableToImport) case .chrome, .chromium, .coccoc, .firefox, .brave, .edge, .opera, .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: + if let browserImportViewController = self.currentChildViewController as? BrowserImportViewController, + browserImportViewController.selectedImportOptions.isEmpty { + self.viewState = ViewState(selectedImportSource: source, interactionState: .unableToImport) + break + } + let interactionState: InteractionState switch (source, loginsSelected) { case (.safari, _), @@ -352,7 +358,7 @@ final class DataImportViewController: NSViewController { let filePermissionViewController = RequestFilePermissionViewController.create(importSource: importSource, permissionsRequired: types, permissionAuthorization: safariDataImporter) filePermissionViewController.delegate = self return filePermissionViewController - } else if browserImportViewController?.browser == importSource { + } else if browserImportViewController?.browser.importSource == importSource { return browserImportViewController } else { browserImportViewController = createBrowserImportViewController(for: importSource) @@ -397,15 +403,16 @@ final class DataImportViewController: NSViewController { } private func createBrowserImportViewController(for source: DataImport.Source) -> BrowserImportViewController? { - // Prevent transitioning to the same view controller. - if let viewController = currentChildViewController as? BrowserImportViewController, viewController.browser == source { return nil } - - guard let browser = ThirdPartyBrowser.browser(for: viewState.selectedImportSource), let profileList = browser.browserProfiles() else { + guard let browser = ThirdPartyBrowser.browser(for: viewState.selectedImportSource) else { assertionFailure("Attempted to create BrowserImportViewController without a valid browser selected") return nil } - let browserImportViewController = BrowserImportViewController.create(with: source, profileList: profileList) + // Prevent transitioning to the same view controller. + if let viewController = currentChildViewController as? BrowserImportViewController, + viewController.browser == browser { return nil } + + let browserImportViewController = BrowserImportViewController.create(with: browser) browserImportViewController.delegate = self return browserImportViewController From fb55c634b296b19cf90416f4a227899cd3db9e35 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 12 Oct 2023 15:35:09 +0600 Subject: [PATCH 13/83] fix tests --- UnitTests/DataImport/BrowserProfileTests.swift | 10 +++++----- UnitTests/DataImport/ThirdPartyBrowserTests.swift | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index 1dc15acb7e..c6732e548b 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -29,7 +29,7 @@ class BrowserProfileListTests: XCTestCase { let fileStore = FileStoreMock() let profile = DataImport.BrowserProfile(browser: .firefox, profileURL: profileURL, fileStore: fileStore) - XCTAssertFalse(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == false) } func testWhenBrowserProfileHasURLWithChromiumLoginData_ThenHasLoginDataIsTrue() { @@ -39,7 +39,7 @@ class BrowserProfileListTests: XCTestCase { fileStore.directoryStorage[profileURL.absoluteString] = ["Login Data"] - XCTAssertTrue(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == true) } func testWhenBrowserProfileHasURLWithFirefoxLoginData_ThenHasLoginDataIsTrue() { @@ -48,13 +48,13 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .firefox, profileURL: profileURL, fileStore: fileStore) fileStore.directoryStorage[profileURL.absoluteString] = ["key4.db"] - XCTAssertFalse(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == false) fileStore.directoryStorage[profileURL.absoluteString] = ["logins.json"] - XCTAssertFalse(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == false) fileStore.directoryStorage[profileURL.absoluteString] = ["logins.json", "key4.db"] - XCTAssertTrue(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == true) } func testWhenGettingProfileName_AndProfileHasNoDetectedName_ThenTheDirectoryNameIsUsed() { diff --git a/UnitTests/DataImport/ThirdPartyBrowserTests.swift b/UnitTests/DataImport/ThirdPartyBrowserTests.swift index 1ae1558f65..e2a6d7b6b7 100644 --- a/UnitTests/DataImport/ThirdPartyBrowserTests.swift +++ b/UnitTests/DataImport/ThirdPartyBrowserTests.swift @@ -76,12 +76,12 @@ class ThirdPartyBrowserTests: XCTestCase { let mockApplicationSupportDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(mockApplicationSupportDirectoryName) try mockApplicationSupportDirectory.writeToTemporaryDirectory() - guard let list = ThirdPartyBrowser.firefox.browserProfiles(supportDirectoryURL: mockApplicationSupportDirectoryURL) else { + guard let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) else { XCTFail("Failed to get profile list") return } - let validProfiles = list.profiles.filter { $0.hasBrowserData } + let validProfiles = list.profiles.filter { $0.validateProfileData()?.containsValidData == true } XCTAssertEqual(validProfiles.count, 2) XCTAssertEqual(list.defaultProfile?.profileName, "default-release") } @@ -102,12 +102,12 @@ class ThirdPartyBrowserTests: XCTestCase { let mockApplicationSupportDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(mockApplicationSupportDirectoryName) try mockApplicationSupportDirectory.writeToTemporaryDirectory() - guard let list = ThirdPartyBrowser.firefox.browserProfiles(supportDirectoryURL: mockApplicationSupportDirectoryURL) else { + guard let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) else { XCTFail("Failed to get profile list") return } - let validProfiles = list.profiles.filter { $0.hasBrowserData } + let validProfiles = list.profiles.filter { $0.validateProfileData()?.containsValidData == true } XCTAssertEqual(validProfiles.count, 1) XCTAssertEqual(list.defaultProfile?.profileName, "default-release") } From c50b7f07d88d8d861c1372fb23f674be1f70499f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 17 Nov 2023 13:07:01 +0600 Subject: [PATCH 14/83] Bitwarden source, Refactor CSV parser, improve column detection (#1740) Task/Issue URL: https://app.asana.com/0/1199178362774117/1202408404935489/f **Description**: - Added Bitwarden CSV Import Source - Refactored CSV parser: - parsing was incorrectly handling quote characters escaped by backslash but should be double quotes instead - now passwords with \ and " should be imported correctly - Improved CSV headers detection to support Bitwarden, Zoho (general + vault formats), RoboForm, Dashlane **Steps to test this PR**: 1. Validate CSV import from password managers stated above as well as existing CSV integrations (1Password, LastPass, Safari) --- ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --- DuckDuckGo/Common/Localizables/UserText.swift | 1 + .../Bookmarks/HTML/BookmarkHTMLReader.swift | 1 + DuckDuckGo/DataImport/DataImport.swift | 10 +- .../DataImport/Logins/CSV/CSVImporter.swift | 198 ++++++++--- .../DataImport/Logins/CSV/CSVParser.swift | 151 ++++++-- .../Logins/Chromium/ChromiumLoginReader.swift | 6 +- .../Logins/Firefox/FirefoxLoginReader.swift | 2 +- .../DataImport/Logins/LoginImport.swift | 49 +-- .../SecureVaultLoginImporter.swift | 2 +- DuckDuckGo/DataImport/ThirdPartyBrowser.swift | 6 +- .../BrowserImportMoreInfoViewController.swift | 2 +- .../DataImport/View/DataImport.storyboard | 334 +++++++++++++++++- .../View/DataImportViewController.swift | 6 +- .../View/FileImportViewController.swift | 17 +- DuckDuckGo/Statistics/PixelArguments.swift | 1 + UnitTests/DataImport/CSVImporterTests.swift | 8 + UnitTests/DataImport/CSVParserTests.swift | 57 ++- 17 files changed, 689 insertions(+), 162 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 00788cc690..d70af1e5cf 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -521,6 +521,7 @@ struct UserText { static let importLoginsSelectBrowserCSVFile = NSLocalizedString("import.logins.select-browser-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a browser CSV file") static let importLoginsSelect1PasswordCSVFile = NSLocalizedString("import.logins.select-1password-csv-file", value: "Select 1Password CSV File…", comment: "Button text for selecting a 1Password CSV file") static let importLoginsSelectLastPassCSVFile = NSLocalizedString("import.logins.select-lastpass-csv-file", value: "Select LastPass CSV File…", comment: "Button text for selecting a LastPass CSV file") + static let importLoginsSelectBitwardenCSVFile = NSLocalizedString("import.logins.select-bitwarden-csv-file", value: "Select Bitwarden CSV File…", comment: "Button text for selecting a Bitwarden CSV file") static let importLoginsSelectAnotherFile = NSLocalizedString("import.logins.select-another-file", value: "Select Another CSV File…", comment: "Button text for selecting another file") static let importLoginsFailedToReadCSVFile = NSLocalizedString("import.logins.failed-to-read-file", value: "Failed to get CSV file URL", comment: "Error text when importing a CSV file") diff --git a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift index 5faf8c6fe5..fdcfe5290f 100644 --- a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift @@ -293,6 +293,7 @@ private extension BookmarkImportSource { .thirdPartyBrowser(.tor), .thirdPartyBrowser(.vivaldi), .thirdPartyBrowser(.yandex), + .thirdPartyBrowser(.bitwarden), .thirdPartyBrowser(.onePassword8), .thirdPartyBrowser(.onePassword7), .thirdPartyBrowser(.lastPass), diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 8c198b08e9..efef2ee0be 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -38,6 +38,7 @@ enum DataImport { case yandex case onePassword8 case onePassword7 + case bitwarden case lastPass case csv case bookmarksHTML @@ -72,6 +73,8 @@ enum DataImport { return "Vivaldi" case .yandex: return "Yandex" + case .bitwarden: + return "Bitwarden" case .lastPass: return "LastPass" case .onePassword7: @@ -95,7 +98,7 @@ enum DataImport { } switch self { - case .csv, .onePassword8, .onePassword7, .lastPass, .bookmarksHTML: + case .csv, .bitwarden, .onePassword8, .onePassword7, .lastPass, .bookmarksHTML: // Users can always import from exported files return true case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: @@ -169,7 +172,7 @@ enum DataImport { self.profiles = profileURLs.map { BrowserProfile(browser: browser, profileURL: $0) }.sorted() - case .lastPass, .onePassword7, .onePassword8: + case .bitwarden, .lastPass, .onePassword7, .onePassword8: self.profiles = [] } } @@ -184,7 +187,7 @@ enum DataImport { return profiles.first { $0.profileName == Constants.chromiumDefaultProfileName } ?? profiles.first case .firefox, .tor: return profiles.first { $0.profileName == Constants.firefoxDefaultProfileName } ?? profiles.first - case .safari, .safariTechnologyPreview, .lastPass, .onePassword7, .onePassword8: + case .safari, .safariTechnologyPreview, .bitwarden, .lastPass, .onePassword7, .onePassword8: return profiles.first } } @@ -366,6 +369,7 @@ struct LoginImporterError: DataImportError { let rawValue: Int static let defaultFirefoxProfilePathNotFound = OperationType(rawValue: -1) + static let malformedCSV = OperationType(rawValue: -2) } var type: OperationType { diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index 32917a1ab5..613df24f10 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -16,62 +16,122 @@ // limitations under the License. // +import Common import Foundation final class CSVImporter: DataImporter { struct ColumnPositions { + private enum Regex { + // should end with "login" or "username" + static let username = regex("(?:^|\\b|\\s|_)(?:login|username)$", .caseInsensitive) + // should end with "password" or "pwd" + static let password = regex("(?:^|\\b|\\s|_)(?:password|pwd)$", .caseInsensitive) + // should end with "name" or "title" + static let title = regex("(?:^|\\b|\\s|_)(?:name|title)$", .caseInsensitive) + // should end with "url", "uri" + static let url = regex("(?:^|\\b|\\s|_)(?:url|uri)$", .caseInsensitive) + // should end with "notes" or "note" + static let notes = regex("(?:^|\\b|\\s|_)(?:notes|note)$", .caseInsensitive) + } + + static let rowFormatWithTitle = ColumnPositions(titleIndex: 0, urlIndex: 1, usernameIndex: 2, passwordIndex: 3) + static let rowFormatWithoutTitle = ColumnPositions(titleIndex: nil, urlIndex: 0, usernameIndex: 1, passwordIndex: 2) + let maximumIndex: Int let titleIndex: Int? - let urlIndex: Int + let urlIndex: Int? + let usernameIndex: Int let passwordIndex: Int - init(titleIndex: Int?, urlIndex: Int, usernameIndex: Int, passwordIndex: Int, maximumIndex: Int) { + let notesIndex: Int? + + let isZohoVault: Bool + + init(titleIndex: Int?, urlIndex: Int?, usernameIndex: Int, passwordIndex: Int, notesIndex: Int? = nil, isZohoVault: Bool = false) { self.titleIndex = titleIndex self.urlIndex = urlIndex self.usernameIndex = usernameIndex self.passwordIndex = passwordIndex - self.maximumIndex = maximumIndex + self.notesIndex = notesIndex + self.maximumIndex = max(titleIndex ?? -1, urlIndex ?? -1, usernameIndex, passwordIndex, notesIndex ?? -1) + self.isZohoVault = isZohoVault } - init?(csvValues: [String]) { - guard csvValues.count >= 3 else { return nil } - - var titlePosition: Int? - var urlPosition: Int? - var usernamePosition: Int? - var passwordPosition: Int? - - for (index, value) in csvValues.enumerated() { - switch value.lowercased() { - case "url", "login_uri": urlPosition = index - case "username", "login_username": usernamePosition = index - case "password", "login_password": passwordPosition = index - case "title", "name": titlePosition = index - default: break - } - } + private enum Format { + case general + case zohoGeneral + case zohoVault + } - if let url = urlPosition, let username = usernamePosition, let password = passwordPosition { - self.init(titleIndex: titlePosition, - urlIndex: url, - usernameIndex: username, - passwordIndex: password, - maximumIndex: csvValues.count - 1) + init?(csv: [[String]]) { + guard csv.count > 1, + csv[1].count >= 3 else { return nil } + var headerRow = csv[0] + + var format = Format.general + + let usernameIndex: Int + if let idx = headerRow.firstIndex(where: { value in + Regex.username.matches(in: value, range: value.fullRange).isEmpty == false + }) { + usernameIndex = idx + headerRow[usernameIndex] = "" + + // Zoho + } else if headerRow.first == "Password Name" { + if let idx = csv[1].firstIndex(of: "SecretData") { + format = .zohoVault + usernameIndex = idx + } else if csv[1].count == 7 { + format = .zohoGeneral + usernameIndex = 5 + } else { + return nil + } } else { return nil } + + let passwordIndex: Int + switch format { + case .general: + guard let idx = headerRow + .firstIndex(where: { !Regex.password.matches(in: $0, range: $0.fullRange).isEmpty }) else { return nil } + passwordIndex = idx + headerRow[passwordIndex] = "" + + case .zohoGeneral: + passwordIndex = usernameIndex + 1 + case .zohoVault: + passwordIndex = usernameIndex + } + + let titleIndex = headerRow.firstIndex(where: { !Regex.title.matches(in: $0, range: $0.fullRange).isEmpty }) + titleIndex.map { headerRow[$0] = "" } + + let urlIndex = headerRow.firstIndex(where: { !Regex.url.matches(in: $0, range: $0.fullRange).isEmpty }) + urlIndex.map { headerRow[$0] = "" } + + let notesIndex = headerRow.firstIndex(where: { !Regex.notes.matches(in: $0, range: $0.fullRange).isEmpty }) + + self.init(titleIndex: titleIndex, + urlIndex: urlIndex, + usernameIndex: usernameIndex, + passwordIndex: passwordIndex, + notesIndex: notesIndex, + isZohoVault: format == .zohoVault) } init?(source: DataImport.Source) { switch source { case .onePassword7, .onePassword8: - self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2, maximumIndex: 7) + self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2) case .lastPass, .firefox, .edge, .chrome, .chromium, .coccoc, .brave, .opera, .operaGX, - .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex, .csv, .bookmarksHTML: + .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex, .csv, .bookmarksHTML, .bitwarden: return nil } } @@ -103,23 +163,19 @@ final class CSVImporter: DataImporter { func totalValidLogins() -> Int { guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8) else { return 0 } - let logins = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) + let logins = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) ?? [] return logins.count } static func extractLogins(from fileContents: String, - defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential] { - let parsed = CSVParser.parse(string: fileContents) + defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential]? { + guard let parsed = try? CSVParser().parse(string: fileContents) else { return nil } - if let possibleHeaderRow = parsed.first, let inferredColumnPositions = ColumnPositions(csvValues: possibleHeaderRow) { - return parsed.dropFirst().compactMap { - ImportedLoginCredential(row: $0, inferredColumnPositions: inferredColumnPositions) - } + if let columnPositions = ColumnPositions(csv: parsed) { + return parsed.dropFirst().compactMap(columnPositions.read) } else { - return parsed.compactMap { - ImportedLoginCredential(row: $0, inferredColumnPositions: defaultColumnPositions) - } + return parsed.compactMap(defaultColumnPositions.read) } } @@ -148,10 +204,12 @@ final class CSVImporter: DataImporter { return } - DispatchQueue.global(qos: .userInitiated).async { - let loginCredentials = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) - + DispatchQueue.global(qos: .userInitiated).async { [defaultColumnPositions] in do { + let loginCredentials = try Self.extractLogins(from: fileContents, + defaultColumnPositions: defaultColumnPositions) ?? { + throw LoginImporterError(source: .csv, error: nil, type: .malformedCSV) + }() let result = try loginImporter.importLogins(loginCredentials) DispatchQueue.main.async { completion(.success(DataImport.Summary(bookmarksResult: nil, loginsResult: .completed(result)))) @@ -173,11 +231,10 @@ extension ImportedLoginCredential { fileprivate var isHeaderRow: Bool { let types: NSTextCheckingResult.CheckingType = [.link] - guard let detector = try? NSDataDetector(types: types.rawValue), self.url.count > 0 else { - return false - } + guard let detector = try? NSDataDetector(types: types.rawValue), + let url, !url.isEmpty else { return false } - if detector.numberOfMatches(in: self.url, options: [], range: self.url.fullRange) > 0 { + if detector.numberOfMatches(in: url, options: [], range: url.fullRange) > 0 { return false } @@ -185,3 +242,54 @@ extension ImportedLoginCredential { } } + +extension CSVImporter.ColumnPositions { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let username: String + let password: String + + if isZohoVault { + // cell contents: + // SecretType:Web Account + // User Name:username + // Password:password + guard let lines = row[safe: usernameIndex]?.components(separatedBy: "\n"), + let usernameLine = lines.first(where: { $0.hasPrefix("User Name:") }), + let passwordLine = lines.first(where: { $0.hasPrefix("Password:") }) else { return nil } + + username = usernameLine.dropping(prefix: "User Name:") + password = passwordLine.dropping(prefix: "Password:") + + } else if let user = row[safe: usernameIndex], + let pass = row[safe: passwordIndex] { + + username = user + password = pass + } else { + return nil + } + + return ImportedLoginCredential(title: row[safe: titleIndex ?? -1], + url: row[safe: urlIndex ?? -1], + username: username, + password: password, + notes: row[safe: notesIndex ?? -1]) + } + +} + +extension CSVImporter.ColumnPositions? { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let columnPositions = self ?? [ + .rowFormatWithTitle, + .rowFormatWithoutTitle + ].first(where: { + row.count > $0.maximumIndex + }) + + return columnPositions?.read(row) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift index 7e1eaebcb9..de99525733 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift @@ -18,53 +18,142 @@ import Foundation -final class CSVParser { +struct CSVParser { - static func parse(string: String) -> [[String]] { - return string.parseCSV() + enum ParserError: Error { + case unexpectedCharacterAfterQuote(Character) } -} + func parse(string: String) throws -> [[String]] { + var parser = Parser() + + for character in string { + try parser.accept(character) + } + + parser.flushField() + + return parser.result + } + + private enum State { + case start + case field + case enquotedField + } -private extension String { + private struct Parser { + var delimiter: Character? - func parseCSV() -> [[String]] { var result: [[String]] = [[]] - var currentField = "".unicodeScalars - var inQuotes = false - var hasPrecedingBackslash = false - @inline(__always) func flush() { - result[result.endIndex - 1].append(String(currentField)) - currentField.removeAll() + var state = State.start + var hasPrecedingQuote = false + + var currentField = "" + + @inline(__always) mutating func flushField() { + result[result.endIndex - 1].append(currentField) + currentField = "" + state = .start + hasPrecedingQuote = false + } + + @inline(__always) mutating func nextLine() { + flushField() + result.append([]) + state = .start + hasPrecedingQuote = false } - for character in self.unicodeScalars { - switch (character, inQuotes, hasPrecedingBackslash) { - case (",", false, _): - hasPrecedingBackslash = false - flush() - case ("\n", false, _): - hasPrecedingBackslash = false - flush() - result.append([]) - case ("\\", true, _): - hasPrecedingBackslash = true - case ("\"", _, false): - inQuotes = !inQuotes - case ("\"", _, true): - // The preceding characters was a backslash, so append the quote to the string instead of treating it as a delimiter - hasPrecedingBackslash = false + // swiftlint:disable:next cyclomatic_complexity + mutating func accept(_ character: Character) throws { + switch (state, character.kind(delimiter: delimiter), precedingQuote: hasPrecedingQuote) { + case (_, .unsupported, _): + return // skip control characters + + // expecting field start + case (.start, .quote, _): + state = .enquotedField + case (.start, .delimiter, _): + flushField() + delimiter = character + case (.start, .whitespace, _): + return // trim leading whitespaces + case (.start, .newline, _): + nextLine() + case (.start, .payload, _): + state = .field + currentField.append(character) + + // quote in field body is escaped with 2 quotes + case (_, .quote, precedingQuote: false): + hasPrecedingQuote = true + case (_, .quote, precedingQuote: true): currentField.append(character) + hasPrecedingQuote = false + + // enquoted field end + case (.enquotedField, .delimiter, precedingQuote: true): + flushField() + delimiter = character + case (.enquotedField, .newline, precedingQuote: true): + nextLine() + case (.enquotedField, .whitespace, precedingQuote: true): + return // trim whitespaces between fields + + // unbalanced quote + case (_, _, precedingQuote: true): + // only expecting a second quote after a quote in field body + throw ParserError.unexpectedCharacterAfterQuote(character) + + // non-enquoted field end + case (.field, .delimiter, _): + flushField() + delimiter = character + case (.field, .newline, _): + nextLine() + default: - hasPrecedingBackslash = false currentField.append(character) } } + } + +} - flush() +private extension Character { - return result + enum Kind { + case quote + case delimiter + case newline + case whitespace + case unsupported + case payload } + func kind(delimiter: Character?) -> Kind { + if self == "\"" { + .quote + } else if self.unicodeScalars.contains(where: { CharacterSet.unsupportedCharacters.contains($0) }) { + .unsupported + } else if CharacterSet.newlines.contains(unicodeScalars.first!) { + .newline + } else if CharacterSet.whitespaces.contains(unicodeScalars.first!) { + .whitespace + } else if self == delimiter || (delimiter == nil && CharacterSet.delimiters.contains(unicodeScalars.first!)) { + .delimiter + } else { + .payload + } + } + +} + +private extension CharacterSet { + + static let unsupportedCharacters = CharacterSet.controlCharacters.union(.illegalCharacters).subtracting(.newlines) + static let delimiters = CharacterSet(charactersIn: ",;") + } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift index db1fdd32e4..b72c5c946e 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift @@ -213,11 +213,7 @@ final class ChromiumLoginReader { return nil } - return ImportedLoginCredential( - url: row.url, - username: row.username, - password: decryptedPassword - ) + return ImportedLoginCredential(url: row.url, username: row.username, password: decryptedPassword) } if result.isEmpty, let lastError { throw lastError diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift index 31f9971995..06fde0a0c9 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift @@ -176,7 +176,7 @@ final class FirefoxLoginReader { currentOperationType = .decryptPassword let decryptedPassword = try decrypt(credential: login.encryptedPassword, key: key) - credentials.append(ImportedLoginCredential(url: login.hostname, username: decryptedUsername, password: decryptedPassword)) + credentials.append(ImportedLoginCredential(url: login.hostname, username: decryptedUsername, password: decryptedPassword, notes: nil)) } catch { lastError = error } diff --git a/DuckDuckGo/DataImport/Logins/LoginImport.swift b/DuckDuckGo/DataImport/Logins/LoginImport.swift index 9c4b569268..f00d28d091 100644 --- a/DuckDuckGo/DataImport/Logins/LoginImport.swift +++ b/DuckDuckGo/DataImport/Logins/LoginImport.swift @@ -21,57 +21,18 @@ import BrowserServicesKit struct ImportedLoginCredential: Equatable { - private enum RowFormatWithTitle: Int { - case title = 0 - case url - case username - case password - } - - private enum RowFormatWithoutTitle: Int { - case url = 0 - case username - case password - } - let title: String? - let url: String + let url: String? let username: String let password: String + let notes: String? - init(title: String? = nil, url: String, username: String, password: String) { + init(title: String? = nil, url: String?, username: String, password: String, notes: String? = nil) { self.title = title - self.url = URL(string: url)?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. + self.url = url.flatMap(URL.init(string:))?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. self.username = username self.password = password - } - - init?(row: [String], inferredColumnPositions: CSVImporter.ColumnPositions? = nil) { - if let inferredPositions = inferredColumnPositions { - guard row.count > inferredPositions.maximumIndex else { return nil } - - var title: String? - - if let titleIndex = inferredPositions.titleIndex { - title = row[titleIndex] - } - - self.init(title: title, - url: row[inferredPositions.urlIndex], - username: row[inferredPositions.usernameIndex], - password: row[inferredPositions.passwordIndex]) - } else if row.count >= 4 { - self.init(title: row[RowFormatWithTitle.title.rawValue], - url: row[RowFormatWithTitle.url.rawValue], - username: row[RowFormatWithTitle.username.rawValue], - password: row[RowFormatWithTitle.password.rawValue]) - } else if row.count >= 3 { - self.init(url: row[RowFormatWithoutTitle.url.rawValue], - username: row[RowFormatWithoutTitle.username.rawValue], - password: row[RowFormatWithoutTitle.password.rawValue]) - } else { - return nil - } + self.notes = notes } } diff --git a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift index aedf2bfb03..2cb2c30df7 100644 --- a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift +++ b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift @@ -41,7 +41,7 @@ final class SecureVaultLoginImporter: LoginImporter { try vault.inDatabaseTransaction { database in for login in logins { let title = login.title - let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url) + let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url, notes: login.notes) let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: login.password.data(using: .utf8)!) let importSummaryValue: String diff --git a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift index b9ad990913..9313461095 100644 --- a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift +++ b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift @@ -43,6 +43,7 @@ enum ThirdPartyBrowser: CaseIterable { case tor case vivaldi case yandex + case bitwarden case lastPass case onePassword7 case onePassword8 @@ -67,6 +68,7 @@ enum ThirdPartyBrowser: CaseIterable { case .tor: return .tor case .vivaldi: return .vivaldi case .yandex: return .yandex + case .bitwarden: return .bitwarden case .lastPass: return .lastPass case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 @@ -102,6 +104,7 @@ enum ThirdPartyBrowser: CaseIterable { case .tor: return .tor case .vivaldi: return .vivaldi case .yandex: return .yandex + case .bitwarden: return .bitwarden case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 case .lastPass: return .lastPass @@ -161,6 +164,7 @@ enum ThirdPartyBrowser: CaseIterable { case .tor: return BundleIdentifiers(productionBundleID: "org.torproject.torbrowser", relatedBundleIDs: []) case .vivaldi: return BundleIdentifiers(productionBundleID: "com.vivaldi.Vivaldi", relatedBundleIDs: []) case .yandex: return BundleIdentifiers(productionBundleID: "ru.yandex.desktop.yandex-browser", relatedBundleIDs: []) + case .bitwarden: return BundleIdentifiers(productionBundleID: "com.bitwarden.desktop", relatedBundleIDs: []) case .onePassword7: return BundleIdentifiers(productionBundleID: "com.agilebits.onepassword7", relatedBundleIDs: [ "com.agilebits.onepassword", "com.agilebits.onepassword4" @@ -229,7 +233,7 @@ enum ThirdPartyBrowser: CaseIterable { case .tor: return applicationSupportURL.appendingPathComponent("TorBrowser-Data/Browser/") case .vivaldi: return applicationSupportURL.appendingPathComponent("Vivaldi/") case .yandex: return applicationSupportURL.appendingPathComponent("Yandex/YandexBrowser/") - case .lastPass, .onePassword7, .onePassword8: return nil + case .bitwarden, .lastPass, .onePassword7, .onePassword8: return nil } } diff --git a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift index ed1942f7ad..0ab2bad1af 100644 --- a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift +++ b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift @@ -55,7 +55,7 @@ final class BrowserImportMoreInfoViewController: NSViewController { case .firefox, .tor: label.stringValue = UserText.importMoreInfo(fromFirefoxBasedBrowserNamed: source.importSourceName) - case .safari, .safariTechnologyPreview, .yandex, .csv, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: + case .safari, .safariTechnologyPreview, .yandex, .csv, .bitwarden, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: fatalError("Unsupported source for more info") } } diff --git a/DuckDuckGo/DataImport/View/DataImport.storyboard b/DuckDuckGo/DataImport/View/DataImport.storyboard index 521875891f..ec9080c180 100644 --- a/DuckDuckGo/DataImport/View/DataImport.storyboard +++ b/DuckDuckGo/DataImport/View/DataImport.storyboard @@ -1253,7 +1253,7 @@ Gw - + @@ -1569,7 +1569,7 @@ Gw - + @@ -1968,13 +1968,13 @@ Gw - + - + - + @@ -2011,13 +2011,13 @@ Gw - + - + - + @@ -2064,10 +2064,10 @@ Gw - + - + @@ -2297,6 +2297,319 @@ Gw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - First line - -Second line - -Third line - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift new file mode 100644 index 0000000000..e9172110f9 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -0,0 +1,67 @@ +// +// DataImportProfilePicker.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct DataImportProfilePicker: View { + + private let profiles: [DataImport.BrowserProfile] + @Binding private var selectedProfile: DataImport.BrowserProfile? + + init(profileList: DataImport.BrowserProfileList?, selectedProfile: Binding) { + self.profiles = profileList?.profiles ?? [] + self._selectedProfile = selectedProfile + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if profiles.count > 1 { + Text("Select Profile:") + .font(.headline) + + Picker(selection: Binding { + selectedProfile.flatMap(profiles.firstIndex(of:)) ?? 0 + } set: { + selectedProfile = profiles[safe: $0] + }) { + ForEach(profiles.indices, id: \.self) { idx in + Text(profiles[idx].profileName) + } + } label: {} + .pickerStyle(MenuPickerStyle()) + } + } + } + +} + +#Preview { + DataImportProfilePicker(profileList: .init(browser: .chrome, profiles: [ + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/test/Default Profile")), + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/test/Profile 1")), + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/test/Profile 2")), + ]), selectedProfile: Binding { + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/test/Profile 1")) + } set: { + print("seiection:", $0) + }) +} diff --git a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift new file mode 100644 index 0000000000..8f1865710d --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift @@ -0,0 +1,64 @@ +// +// DataImportSourcePicker.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@MainActor +struct DataImportSourcePicker: View { + + @State private var viewModel: DataImportSourceViewModel + + private var importSources: [DataImport.Source] { + viewModel.importSources + } + + init(selectedSource: DataImport.Source, + onSelectedSourceChanged: @escaping (DataImport.Source) -> Void) { + self.viewModel = DataImportSourceViewModel(selectedSource: selectedSource, onSelectedSourceChanged: onSelectedSourceChanged) + } + + var body: some View { + Picker(selection: $viewModel.selectedSourceIndex) { + ForEach(importSources.indices, id: \.self) { idx in + HStack { + // The CSV row is at the bottom of the picker, and requires a separator above it, but only if the item array isn't + // empty (which would happen if there are no valid sources). + if idx > 0, [.onePassword8, .csv].contains(importSources[idx]) { + Divider() + } + + if let icon = importSources[idx].importSourceImage?.resized(to: NSSize(width: 16, height: 16)) { + Image(nsImage: icon) + } + Text(importSources[idx].importSourceName) + } + } + } label: {} + .onChange(of: viewModel.selectedSourceIndex) { idx in + viewModel.onSelectedSourceChanged(importSources[idx]) + } + .pickerStyle(MenuPickerStyle()) + } + +} + +#Preview { + DataImportSourcePicker(selectedSource: .csv) { + print("seiection:", $0) + } +} diff --git a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift new file mode 100644 index 0000000000..443279f762 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift @@ -0,0 +1,92 @@ +// +// DataImportSummaryView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct DataImportSummaryView: View { + + typealias DataType = DataImport.DataType + typealias Summary = DataImport.DataTypeSummary + + let summary: [DataType: Summary] + + init(summary: [DataType: Summary]) { + self.summary = summary + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(DataType.allCases.compactMap { + guard let result = summary[$0] else { return nil } + return (dataType: $0, result: result) + }, id: \.dataType) { (item: (dataType: DataType, result: Summary)) in + switch item.dataType { + case .bookmarks: + HStack { + successImage() + Text("Bookmarks: \(item.result.successful)") + } + if item.result.duplicate > 0 { + HStack { + failureImage() + Text("Duplicate Bookmarks Skipped: \(item.result.duplicate)") + } + } + if item.result.failed > 0 { + HStack { + failureImage() + Text("Bookmark import failed: \(item.result.failed)") + } + } + + case .passwords: + HStack { + successImage() + Text("Passwords: \(item.result.successful)") + } + if item.result.failed > 0 { + HStack { + failureImage() + Text("Login import failed: \(item.result.failed)") + } + } + } + } + } + } + +} + +private func successImage() -> some View { + Image(.successCheckmark) + .frame(width: 16, height: 16) +} + +private func failureImage() -> some View { + Image(.error) + .frame(width: 16, height: 16) +} + +#Preview { + DataImportSummaryView(summary: [ + .bookmarks: .init(successful: 123, duplicate: 456, failed: 7890), + .passwords: .init(successful: 123, duplicate: 456, failed: 7890) + ]) + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + .frame(width: 512) +} diff --git a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift new file mode 100644 index 0000000000..1b368e3cb5 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift @@ -0,0 +1,71 @@ +// +// DataImportTypePicker.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct DataImportTypePicker: View { + + @Binding var viewModel: DataImportViewModel + + init(viewModel: Binding) { + _viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading) { + Text("Select data to import:") + .font(.headline) + + ForEach(DataImport.DataType.allCases, id: \.self) { dataType in + // display all types for a browser disabling unavailable options + if viewModel.importSource.isBrowser + // display only supported types for a non-browser + || viewModel.importSource.supportedDataTypes.contains(dataType) { + + Toggle(isOn: Binding { + viewModel.selectedDataTypes.contains(dataType) + } set: { isOn in + viewModel.setDataType(dataType, selected: isOn) + }) { + Text(dataType.displayName) + } + .disabled(!viewModel.importSource.supportedDataTypes.contains(dataType)) + + // subtitle + if !viewModel.importSource.supportedDataTypes.contains(dataType) { + Text("\(viewModel.importSource.importSourceName) does not support storing \(dataType.displayName)") + .foregroundColor(Color(.disabledControlTextColor)) + } + } + } + } + } + +} + +extension DataImportViewModel { + + mutating func setDataType(_ dataType: DataType, selected: Bool) { + if selected { + selectedDataTypes.insert(dataType) + } else { + selectedDataTypes.remove(dataType) + } + } + +} diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift new file mode 100644 index 0000000000..b1ba95a136 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -0,0 +1,438 @@ +// +// DataImportView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import SwiftUI + +extension DataImportView { + + @MainActor + static func show(completion: (() -> Void)? = nil) { + guard let window = WindowControllersManager.shared.lastKeyMainWindowController?.window else { return } + + if !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + + let sheetWindow = SheetHostingWindow(rootView: DataImportView()) + + window.beginSheet(sheetWindow, completionHandler: completion.map { completion in + { _ in + completion() + } + }) + } + +} + +struct DataImportView: View { + + @Environment(\.dismiss) private var dismiss + + @State var viewModel = DataImportViewModel() + + @State private var progressText: String? + @State private var progressFraction: Double? + + private func feedbackComment() -> Binding { + Binding { + guard case .feedback(let comment) = viewModel.screen else { + assertionFailure("wrong screen") + return "" + } + return comment + } set: { + viewModel.updateFeedbackComment($0) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Import Browser Data") + .font(.headline) + Spacer().frame(height: 10) + + // browser to import data from picker popup + if case .feedback = viewModel.screen {} else { + DataImportSourcePicker(selectedSource: viewModel.importSource) { importSource in + viewModel.update(with: importSource) + } + .disabled(viewModel.isImportSourcePickerDisabled) + } + + Spacer().frame(height: 16) + + // body + switch viewModel.screen { + case .profileAndDataTypesPicker: + // Browser Profile picker + DataImportProfilePicker(profileList: viewModel.browserProfiles, + selectedProfile: $viewModel.selectedProfile) + .disabled(viewModel.isImportSourcePickerDisabled) + + Spacer().frame(height: 16) + + // Bookmarks/Passwords checkboxes + DataImportTypePicker(viewModel: $viewModel) + .disabled(viewModel.isImportSourcePickerDisabled) + + case .moreInfo: + // you will be asked for your keychain password blah blah... + BrowserImportMoreInfoView(source: viewModel.importSource) + + case .getReadPermission(let url): + // give request to Safari folder, select Bookmarks.plist using open panel + RequestFilePermissionView(source: viewModel.importSource, url: url, requestDataDirectoryPermission: SafariDataImporter.requestDataDirectoryPermission) { _ in + + viewModel.initiateImport() + } + + case .fileImport(let dataType): + // if browser importer failed - display error message + if viewModel.hasDataTypeImportFailed(dataType) { + Text("We were unable to import directly from \(viewModel.importSource.importSourceName).") + .font(.headline) + Spacer().frame(height: 8) + Text("Let’s try doing it manually. It won’t take long.") + Spacer().frame(height: 24) + } + + // manual file import instructions for CSV/HTML + FileImportView(source: viewModel.importSource, dataType: dataType) { + viewModel.selectFile() + } + + case .fileImportSummary(let dataType): + // present file impoter import summary for one data type + Text("\(dataType.displayName) Import Complete") + .font(.headline) + Spacer().frame(height: 12) + DataImportSummaryView(summary: ( + try? viewModel.summary.last(where: { + $0.dataType == dataType + })?.result.get() + ).map { [dataType: $0] } ?? [:]) + + case .summary: + // total import summary + Text("Import Complete") + .font(.headline) + Spacer().frame(height: 12) + + // import completed + DataImportSummaryView(summary: viewModel.summary.reduce(into: [:]) { + $0[$1.dataType] = try? $1.result.get() + }) + + case .feedback: + ReportFeedbackView(text: feedbackComment(), + retryNumber: viewModel.summary.reduce(into: [:]) { + // get maximum number of failures per data type + $0[$1.dataType, default: 0] += $1.result.isSuccess ? 0 : 1 + }.values.max() ?? 0, + importSource: viewModel.importSource, + error: viewModel.summarizedError) + } + + // Import in progress… + if let importProgress = viewModel.importProgress { + Spacer().frame(height: 24) + + // Progress bar with label: Importing [bookmarks|passwords]… + ProgressView(value: progressFraction) { + Text(progressText ?? "") + } + .task { + // when viewModel.importProgress async sequence not nil + // receive progress updates events and update model on completion + await handleImportProgress(importProgress) + } + + } + + Spacer().frame(height: 32) + Divider() + Spacer().frame(height: 24) + + // under line buttons + HStack { + Spacer() + + ForEach(viewModel.buttons, id: \.type) { button in + Button(button.type.title) { + viewModel.performAction(for: button.type, dismiss: dismiss.callAsFunction) + } + .keyboardShortcut(button.type.shortcut) + .disabled(button.isDisabled) + } + } + } + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + .frame(width: 512) + .fixedSize() + } + + private func handleImportProgress(_ progress: TaskProgress) async { + // receive import progress update events + // the loop is completed on the import task + // cancellation/completion or on did disappear + for await event in progress { + switch event { + case .progress(let progress): + progressText = progress.description + progressFraction = progress.fraction + + // update view model on completion + case .completed(.success(let viewModel)): + self.viewModel = viewModel + } + } + } + +} + +extension DataImportProgressEvent { + + var fraction: Double? { + switch self { + case .initial: + nil + case .importingBookmarks(numberOfBookmarks: _, fraction: let fraction): + fraction + case .importingPasswords(numberOfPasswords: _, fraction: let fraction): + fraction + case .done: + nil + } + } + + var description: String? { + switch self { + case .initial: + nil + case .importingBookmarks(numberOfBookmarks: let num, fraction: _): + UserText.importingBookmarks(num) + case .importingPasswords(numberOfPasswords: let num, fraction: _): + UserText.importingPasswords(num) + case .done: + nil + } + } + +} + +extension DataImportViewModel.ButtonType { + + var shortcut: KeyboardShortcut? { + switch self { + case .next: .defaultAction + case .initiateImport: .defaultAction + case .skip: .cancelAction + case .cancel: .cancelAction + case .back: nil + case .done: .defaultAction + case .submit: .defaultAction + } + } + +} + +#Preview { { + + final class PreviewPreferences: ObservableObject { + @Published var shouldBookmarkImportFail = false + @Published var shouldPasswordsImportFail = false + @Published var shouldDisplayProgress = false + + static let shared = PreviewPreferences() + } + + final class MockDataImporter: DataImporter { + + struct MockError: Error { } + + enum ImportError: DataImportError { + enum OperationType: Int { + case imp + } + + var type: OperationType { .imp } + var source: DataImport.Source { .chrome } + var action: DataImportAction { .generic } + var underlyingError: Error? { + if case .err(let err) = self { + return err + } + return nil + } + + case err(Error) + } + let source: DataImport.Source + var dataType: DataImport.DataType? + var importableTypes: [DataImport.DataType] { + [.safari, .yandex].contains(source) && dataType == nil ? [.bookmarks] : [.bookmarks, .passwords] + } + + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + source == .firefox && types.contains(.passwords) ? [.passwords: FirefoxLoginReader.ImportError(source: .firefox, type: .requiresPrimaryPassword, underlyingError: nil)] : nil + } + + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + source == .chrome && selectedDataTypes.contains(.passwords) ? true : false + } + + init(source: DataImport.Source, dataType: DataImport.DataType? = nil) { + self.source = source + self.dataType = dataType + } + + // swiftlint:disable:next function_body_length + func importData(types: Set) -> DataImportTask { + .detachedWithProgress(.initial) { progressUpdate in + func makeProgress(_ op: (Double) throws -> Void) async throws { + guard PreviewPreferences.shared.shouldDisplayProgress else { return } + let n = 20 + for i in 0.. { print("DISMISS!") }) + + VStack(alignment: .leading, spacing: 10) { + Spacer() + Divider().frame(width: 512) + PreviewPreferencesView() + + }.background(Color(NSColor(red: 1, green: 0, blue: 0, alpha: 0.3))) + } + .frame(minHeight: 500) + +}() } diff --git a/DuckDuckGo/DataImport/View/DataImportViewController.swift b/DuckDuckGo/DataImport/View/DataImportViewController.swift deleted file mode 100644 index 03b1b58b57..0000000000 --- a/DuckDuckGo/DataImport/View/DataImportViewController.swift +++ /dev/null @@ -1,657 +0,0 @@ -// -// DataImportViewController.swift -// -// Copyright © 2021 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import AppKit -import BrowserServicesKit -import Combine -import Common - -final class DataImportViewController: NSViewController { - - @UserDefaultsWrapper(key: .homePageContinueSetUpImport, defaultValue: nil) - var successfulImportHappened: Bool? - - private enum Constants { - static let storyboardName = "DataImport" - static let identifier = "DataImportViewController" - } - - private enum InteractionState: Equatable { - case unableToImport - case permissionsRequired([DataImport.DataType]) - case ableToImport - case moreInfoAvailable - case completedImport(DataImport.Summary) - } - - private struct ImportError: DataImportError { - enum OperationType: Int { - case secureVaultError - case selectProfile - } - - let source: DataImport.Source - let action: DataImportAction - let type: OperationType - let underlyingError: Error? - } - - private struct ViewState: Equatable { - var selectedImportSource: DataImport.Source - var interactionState: InteractionState - - static func defaultState() -> ViewState { - if let firstInstalledBrowser = ThirdPartyBrowser.installedBrowsers.first { - return ViewState(selectedImportSource: firstInstalledBrowser.importSource, - interactionState: [.safari, .safariTechnologyPreview].contains(firstInstalledBrowser.importSource) ? .ableToImport : .moreInfoAvailable) - } else { - return ViewState(selectedImportSource: .csv, interactionState: .ableToImport) - } - } - } - - static func show(completion: (() -> Void)? = nil) { - guard let windowController = WindowControllersManager.shared.lastKeyMainWindowController else { return } - if windowController.window?.isKeyWindow != true { - windowController.window?.makeKeyAndOrderFront(nil) - } - - let viewController = DataImportViewController.create() - windowController.mainViewController.beginSheet(viewController) { _ in - completion?() - } - } - - private static func create() -> DataImportViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - return storyboard.instantiateController(identifier: Constants.identifier) - } - - private func secureVaultImporter() throws -> SecureVaultLoginImporter { - let secureVault = try AutofillSecureVaultFactory.makeVault(errorReporter: SecureVaultErrorReporter.shared) - return SecureVaultLoginImporter(secureVault: secureVault) - } - - private var viewState: ViewState = .defaultState() { - didSet { - renderCurrentViewState() - refreshDataImporter() - } - } - - private weak var currentChildViewController: NSViewController? - private var browserImportViewController: BrowserImportViewController? - - private var bookmarkCount = 0 - - private var dataImporter: DataImporter? - private var selectedImportSourceCancellable: AnyCancellable? - - @IBOutlet var containerView: NSView! - @IBOutlet var importSourcePopUpButton: NSPopUpButton! - @IBOutlet var importButton: NSButton! - @IBOutlet var cancelButton: NSButton! - - @IBAction func cancelButtonClicked(_ sender: Any) { - if currentChildViewController is BrowserImportMoreInfoViewController { - viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .moreInfoAvailable) - importSourcePopUpButton.isEnabled = true - cancelButton.title = UserText.cancel - } else { - dismiss() - } - } - - @IBAction func actionButtonClicked(_ sender: Any) { - switch viewState.interactionState { - // Import click on first screen with Bookmarks checkmark: Can't read bookmarks: request permission - case .ableToImport where [.safari, .safariTechnologyPreview].contains(viewState.selectedImportSource) - && selectedImportOptions.contains(.bookmarks) - && (dataImporter as? DataDirectoryPermissionAuthorization)?.canReadBookmarksFile() == false: - - self.viewState = ViewState(selectedImportSource: viewState.selectedImportSource, interactionState: .permissionsRequired([.bookmarks])) - - // Import click on first screen with Passwords bookmark or Next click on Bookmarks Import Done screen: show CSV Import - case .ableToImport where [.safari, .safariTechnologyPreview, .yandex].contains(viewState.selectedImportSource) - && selectedImportOptions.contains(.logins) - && (dataImporter is CSVImporter || selectedImportOptions == [.logins]) - && !(currentChildViewController is FileImportViewController): - // Only Safari Passwords selected, switch to CSV select - self.viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .permissionsRequired([.logins])) - - case .ableToImport: - completeImport() - case .completedImport(let summary) where summary.loginsResult == .awaited: - // Safari bookmarks import finished, switch to CSV select - self.viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .permissionsRequired([.logins])) - case .completedImport: - dismiss() - case .moreInfoAvailable: - showMoreInfo() - default: - assertionFailure("\(#file): Import button should be disabled when unable to import") - } - } - - // MARK: - View Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - importSourcePopUpButton.displayImportSources() - renderCurrentViewState() - refreshDataImporter() - - selectedImportSourceCancellable = importSourcePopUpButton.selectionPublisher.sink { [weak self] _ in - self?.refreshViewState() - } - } - - override func viewDidDisappear() { - super.viewDidDisappear() - if bookmarkCount > 0 { - NotificationCenter.default.post(name: .bookmarkPromptShouldShow, object: nil) - } - } - - private func refreshViewState() { - guard let item = self.importSourcePopUpButton.selectedImportSourceItem(withPreferredIndex: importSourcePopUpButton.indexOfSelectedItem) else { - pixelAssertionFailure("Failed to get valid import source item") - return - } - - let validSources = DataImport.Source.allCases.filter(\.canImportData) - let source = validSources.first(where: { $0.importSourceName == item.title })! - - switch source { - case .csv, .bitwarden, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: - self.viewState = ViewState(selectedImportSource: source, interactionState: .unableToImport) - - case .chrome, .chromium, .coccoc, .firefox, .brave, .edge, .opera, .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: - if let browserImportViewController = self.currentChildViewController as? BrowserImportViewController, - browserImportViewController.selectedImportOptions.isEmpty { - self.viewState = ViewState(selectedImportSource: source, interactionState: .unableToImport) - break - } - - let interactionState: InteractionState - switch (source, loginsSelected) { - case (.safari, _), - (.safariTechnologyPreview, _), - (.yandex, _), - (_, false): - interactionState = .ableToImport - case (.firefox, _): - if FirefoxDataImporter.loginDatabaseRequiresPrimaryPassword(profileURL: selectedProfile?.profileURL) { - interactionState = .moreInfoAvailable - } else { - interactionState = .ableToImport - } - case (_, true): - interactionState = .moreInfoAvailable - } - - let newState = ViewState(selectedImportSource: source, interactionState: interactionState) - - if newState != self.viewState { - self.viewState = newState - } - } - } - - private func refreshDataImporter() { - do { - try throwingRefreshDataImporter() - } catch let error as ImportError { - os_log("dataImporter initialization failed: %{public}s", type: .error, error.localizedDescription) - self.presentAlert(for: error) - } catch { - os_log("dataImporter initialization failed: %{public}s", type: .error, error.localizedDescription) - self.presentAlert(for: ImportError(source: viewState.selectedImportSource, action: .generic, type: .secureVaultError, underlyingError: error)) - } - } - - // swiftlint:disable:next cyclomatic_complexity - private func throwingRefreshDataImporter() throws { - let bookmarkImporter = CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared) - - switch viewState.selectedImportSource { - case .brave: - self.dataImporter = try BraveDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .chrome: - self.dataImporter = try ChromeDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .chromium: - self.dataImporter = try ChromiumDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .coccoc: - self.dataImporter = try CoccocDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .edge: - self.dataImporter = try EdgeDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .opera, .operaGX: - self.dataImporter = try OperaDataImporter(loginImporter: secureVaultImporter(), - bookmarkImporter: bookmarkImporter, - gx: viewState.selectedImportSource == .operaGX) - case .vivaldi: - self.dataImporter = try VivaldiDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - - case .firefox, .tor: - self.dataImporter = try FirefoxDataImporter(loginImporter: secureVaultImporter(), - bookmarkImporter: bookmarkImporter, - faviconManager: FaviconManager.shared) - case .safari where !(currentChildViewController is FileImportViewController), - .safariTechnologyPreview where !(currentChildViewController is FileImportViewController): - - self.dataImporter = try SafariDataImporter(importSource: viewState.selectedImportSource, - bookmarkImporter: bookmarkImporter, - faviconManager: FaviconManager.shared) ?? { - throw ImportError(source: viewState.selectedImportSource, action: .generic, type: .selectProfile, underlyingError: nil) - }() - - case .yandex where !(currentChildViewController is FileImportViewController): - self.dataImporter = YandexDataImporter(bookmarkImporter: bookmarkImporter) - - case .bookmarksHTML: - if !(self.dataImporter is BookmarkHTMLImporter) { - self.dataImporter = nil - } - case .csv, .bitwarden, .onePassword7, .onePassword8, .lastPass, - .safari, .safariTechnologyPreview, .yandex /* csv only */: - if !(self.dataImporter is CSVImporter) { - self.dataImporter = nil - } - } - } - - private var loginsSelected: Bool { - if let browserViewController = self.currentChildViewController as? BrowserImportViewController { - return browserViewController.selectedImportOptions.contains(.logins) - } - - // Assume true as a default in order to show next button when new child view controller is set - return true - } - - private func renderCurrentViewState() { - updateActionButton(with: viewState.interactionState) - - if let viewController = newChildViewController(for: viewState.selectedImportSource, interactionState: viewState.interactionState) { - embed(viewController: viewController) - } - } - - private func updateActionButton(with interactionState: InteractionState) { - switch interactionState { - case .unableToImport: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.initiateImport - importButton.isEnabled = false - cancelButton.isHidden = false - case .ableToImport: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.initiateImport - importButton.isEnabled = true - cancelButton.isHidden = false - case .moreInfoAvailable: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.next - importButton.isEnabled = true - cancelButton.isHidden = false - case .permissionsRequired: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.initiateImport - importButton.isEnabled = false - cancelButton.isHidden = false - case .completedImport(let summary) where summary.loginsResult == .awaited: - // Continue to Logins import from Safari - importSourcePopUpButton.isHidden = false - importButton.title = UserText.next - importButton.isEnabled = true - cancelButton.isHidden = false - case .completedImport: - importSourcePopUpButton.isHidden = true - importButton.title = UserText.doneImporting - importButton.isEnabled = true - cancelButton.isHidden = true - } - } - - private func newChildViewController(for importSource: DataImport.Source, interactionState: InteractionState) -> NSViewController? { - switch importSource { - case .safari, .safariTechnologyPreview, .yandex: - if case .permissionsRequired([.logins]) = interactionState { - let viewController = FileImportViewController.create(importSource: importSource) - viewController.delegate = self - - return viewController - } else if case .ableToImport = interactionState, - let fileImportViewController = currentChildViewController as? FileImportViewController, - [.safari, .safariTechnologyPreview, .yandex].contains(fileImportViewController.importSource) { - fileImportViewController.importSource = importSource - - return nil - } - - fallthrough - case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .tor, .vivaldi: - if case let .completedImport(summary) = interactionState { - return BrowserImportSummaryViewController.create(importSummary: summary) - } else if case let .permissionsRequired(types) = interactionState { - guard let safariDataImporter = dataImporter as? DataDirectoryPermissionAuthorization else { - assertionFailure("Unexpected data importer kind: \(dataImporter.map(String.init(describing:)) ?? "")") - return nil - } - - let filePermissionViewController = RequestFilePermissionViewController.create(importSource: importSource, permissionsRequired: types, permissionAuthorization: safariDataImporter) - filePermissionViewController.delegate = self - return filePermissionViewController - } else if browserImportViewController?.browser.importSource == importSource { - return browserImportViewController - } else { - browserImportViewController = createBrowserImportViewController(for: importSource) - return browserImportViewController - } - - case .csv, .bitwarden, .onePassword7, .onePassword8, .lastPass, .bookmarksHTML: - if case let .completedImport(summary) = interactionState { - return BrowserImportSummaryViewController.create(importSummary: summary) - } else { - if let fileImportViewController = currentChildViewController as? FileImportViewController { - fileImportViewController.importSource = importSource - return nil - } - let viewController = FileImportViewController.create(importSource: importSource) - viewController.delegate = self - return viewController - } - } - } - - private func embed(viewController newChildViewController: NSViewController) { - if newChildViewController === currentChildViewController { - return - } - - let oldChildViewController = currentChildViewController - currentChildViewController = newChildViewController - - addChild(newChildViewController) - if let oldChildViewController { - transition(from: oldChildViewController, to: newChildViewController, options: []) - } - - containerView.addSubview(newChildViewController.view) - - newChildViewController.view.translatesAutoresizingMaskIntoConstraints = false - newChildViewController.view.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true - newChildViewController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true - newChildViewController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true - newChildViewController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true - } - - private func createBrowserImportViewController(for source: DataImport.Source) -> BrowserImportViewController? { - guard let browser = ThirdPartyBrowser.browser(for: viewState.selectedImportSource) else { - assertionFailure("Attempted to create BrowserImportViewController without a valid browser selected") - return nil - } - - // Prevent transitioning to the same view controller. - if let viewController = currentChildViewController as? BrowserImportViewController, - viewController.browser == browser { return nil } - - let browserImportViewController = BrowserImportViewController.create(with: browser) - browserImportViewController.delegate = self - - return browserImportViewController - } - - // MARK: - Actions - - private func showMoreInfo() { - viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .ableToImport) - importSourcePopUpButton.isEnabled = false - embed(viewController: BrowserImportMoreInfoViewController.create(source: viewState.selectedImportSource)) - cancelButton.title = UserText.navigateBack - } - - private var selectedProfile: DataImport.BrowserProfile? { - return browserImportViewController?.selectedProfile - } - - private var selectedImportOptions: [DataImport.DataType] { - guard let importer = self.dataImporter else { - assertionFailure("\(#file): No data importer or profile found") - return [] - } - let selectedOptions = browserImportViewController?.selectedImportOptions ?? [] - return selectedOptions.isEmpty ? importer.importableTypes() : selectedOptions - } - - private func completeImport() { - guard let importer = self.dataImporter else { - assertionFailure("\(#file): No data importer or profile found") - return - } - - let profile = selectedProfile - let importTypes = selectedImportOptions - - self.importButton.isEnabled = false - self.cancelButton.isEnabled = false - - importer.importData(types: importTypes, from: profile, modalWindow: view.window) { result in - self.importButton.isEnabled = true - self.cancelButton.isEnabled = true - - switch result { - case .success(let summary): - self.successfulImportHappened = true - if summary.isEmpty { - self.dismiss() - } else { - self.bookmarkCount += summary.bookmarksResult?.successful ?? 0 - self.viewState.interactionState = .completedImport(summary) - self.requestSync() - } - - NotificationCenter.default.post(name: .dataImportComplete, object: nil) - - case .failure(let error as ChromiumLoginReader.ImportError) where error.type == .userDeniedKeychainPrompt: - // user denied Keychain prompt: keep current screen - Pixel.fire(.dataImportFailed(error)) - - case .failure(let error as FirefoxLoginReader.ImportError) where error.type == .requiresPrimaryPassword: - self.requestPrimaryPassword() - - case .failure(let error): - if (error as? FirefoxLoginReader.ImportError)?.type != .requiresPrimaryPassword { - os_log("import failed: %{public}s", type: .error, error.localizedDescription) - self.viewState.interactionState = .ableToImport - } - self.presentAlert(for: error) - } - } - } - - private func requestPrimaryPassword() { - let alert = NSAlert.passwordRequiredAlert(source: viewState.selectedImportSource) - let response = alert.runModal() - - if response == .alertFirstButtonReturn { - // Assume Firefox, as it's the only supported option that uses a password - let password = (alert.accessoryView as? NSSecureTextField)?.stringValue - (dataImporter as? FirefoxDataImporter)?.primaryPassword = password - - completeImport() - } - } - - private func presentAlert(for error: any DataImportError) { - guard let window = view.window else { return } - - Pixel.fire(.dataImportFailed(error)) - - let alert = NSAlert.importFailedAlert(linkDelegate: self) - alert.beginSheetModal(for: window, completionHandler: nil) - } - - private func requestSync() { - guard let syncService = NSApp.delegateTyped.syncService else { - return - } - os_log(.debug, log: OSLog.sync, "Requesting sync if enabled") - syncService.scheduler.requestSyncImmediately() - } - -} - -extension DataImportViewController: FileImportViewControllerDelegate { - - func fileImportViewController(_ viewController: FileImportViewController, didSelectBookmarksFileWithURL url: URL?) { - guard let url else { - self.viewState.interactionState = .unableToImport - return - } - - let bookmarkImporter = CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared) - - self.dataImporter = BookmarkHTMLImporter(fileURL: url, bookmarkImporter: bookmarkImporter) - self.viewState.interactionState = .ableToImport - } - - func fileImportViewController(_ viewController: FileImportViewController, didSelectCSVFileWithURL url: URL?) { - guard let url else { - self.viewState.interactionState = .unableToImport - return - } - - do { - let secureVault = try AutofillSecureVaultFactory.makeVault(errorReporter: SecureVaultErrorReporter.shared) - let secureVaultImporter = SecureVaultLoginImporter(secureVault: secureVault) - self.dataImporter = CSVImporter(fileURL: url, - loginImporter: secureVaultImporter, - defaultColumnPositions: .init(source: self.viewState.selectedImportSource)) - self.viewState.interactionState = .ableToImport - } catch { - os_log("file import failed: %{public}s", type: .error, error.localizedDescription) - self.viewState.interactionState = .unableToImport - self.presentAlert(for: ImportError(source: self.viewState.selectedImportSource, action: .logins, type: .secureVaultError, underlyingError: error)) - } - } - - func totalValidLogins(in url: URL) -> Int? { - - let importer = CSVImporter(fileURL: url, - loginImporter: nil, - defaultColumnPositions: .init(source: self.viewState.selectedImportSource)) - return importer.totalValidLogins() - } - - func totalValidBookmarks(in fileURL: URL) -> Int? { - let importer = BookmarkHTMLImporter( - fileURL: fileURL, - bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared) - ) - return importer.totalBookmarks - } - -} - -extension DataImportViewController: BrowserImportViewControllerDelegate { - - func browserImportViewController(_ viewController: BrowserImportViewController, didChangeSelectedImportOptions options: [DataImport.DataType]) { - if options.isEmpty { - viewState.interactionState = .unableToImport - } else { - refreshViewState() - } - } - - func browserImportViewControllerRequestedParentViewRefresh(_ viewController: BrowserImportViewController) { - refreshViewState() - } - -} - -extension DataImportViewController: RequestFilePermissionViewControllerDelegate { - - func requestFilePermissionViewControllerDidReceivePermission(_ viewController: RequestFilePermissionViewController) { - if [.safari, .safariTechnologyPreview].contains(viewState.selectedImportSource) - && selectedImportOptions.contains(.bookmarks) { - - self.completeImport() - return - } - - self.viewState.interactionState = .ableToImport - } - -} - -extension DataImportViewController: NSTextViewDelegate { - - func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { - guard let sheet = view.window?.attachedSheet else { - return false - } - - view.window?.endSheet(sheet) - dismiss() - - FeedbackPresenter.presentFeedbackForm() - - return true - } - -} - -extension NSPopUpButton { - - fileprivate func displayImportSources() { - removeAllItems() - - let validSources = DataImport.Source.allCases.filter(\.canImportData) - for source in validSources { - // The CSV row is at the bottom of the picker, and requires a separator above it, but only if the item array isn't - // empty (which would happen if there are no valid sources). - if (source == .onePassword8 || source == .csv) && !itemArray.isEmpty { - - let separator = NSMenuItem.separator() - menu?.addItem(separator) - } - - addItem(withTitle: source.importSourceName) - lastItem?.image = source.importSourceImage?.resized(to: NSSize(width: 16, height: 16)) - } - - if let preferredSource = DataImport.Source.preferredSources.first(where: { validSources.contains($0) }) { - selectItem(withTitle: preferredSource.importSourceName) - } - } - - /// Provides a safe way to extract the selected import source item from an `NSPopUpButton`. A pop up button can include a separator at the top, so the fallback logic of treating the first item as - /// selected means it's possible to get a separator as the selected import source. This function will check that the title is not empty and that the preferred index exists when checking for - /// the selected item, and will check subsequent items if the non-empty title condition is not met. - fileprivate func selectedImportSourceItem(withPreferredIndex index: Int) -> NSMenuItem? { - guard !itemArray.isEmpty, index != NSNotFound else { - assertionFailure("Failed to select an import source item") - return nil - } - - return itemArray[index...].first { !$0.title.isEmpty } - } - -} diff --git a/DuckDuckGo/DataImport/View/FileImportSummaryViewController.swift b/DuckDuckGo/DataImport/View/FileImportSummaryViewController.swift deleted file mode 100644 index 1399c13afc..0000000000 --- a/DuckDuckGo/DataImport/View/FileImportSummaryViewController.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// FileImportSummaryViewController.swift -// -// Copyright © 2021 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import AppKit - -final class FileImportSummaryViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "FileImportSummaryViewController" - } - - static func create(summary: DataImport.Summary?) -> FileImportSummaryViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - - return storyboard.instantiateController(identifier: Constants.identifier) { (coder) -> FileImportSummaryViewController? in - return FileImportSummaryViewController(coder: coder, summary: summary) - } - } - - @IBOutlet var importCompleteLabel: NSTextField! - - @IBOutlet var successfulImportsLabel: NSTextField! - - private let summary: DataImport.Summary? - - init?(coder: NSCoder, summary: DataImport.Summary?) { - self.summary = summary - super.init(coder: coder) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - if case .completed(let result) = summary?.loginsResult { - successfulImportsLabel.stringValue = UserText.loginImportSuccessfulCSVImports(totalSuccessfulImports: result.successfulImports.count) - } else { - successfulImportsLabel.isHidden = true - } - } - -} diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift new file mode 100644 index 0000000000..f7fbbf9ec8 --- /dev/null +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -0,0 +1,303 @@ +// +// FileImportView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct FileImportView: View { + + let source: DataImport.Source + let dataType: DataImport.DataType + let action: (() -> Void) + private let instructions: [[FileImportInstructionsItem]] + + init(source: DataImport.Source, dataType: DataImport.DataType, action: (() -> Void)? = nil) { + self.source = source + self.dataType = dataType + self.action = action ?? {} + self.instructions = Self.instructions(for: source, dataType: dataType) + } + + // swiftlint:disable:next function_body_length + private static func instructions(for source: DataImport.Source, dataType: DataImport.DataType) -> [[FileImportInstructionsItem]] { + buildInstructions { + switch (source, dataType) { + case (.brave, .passwords), + (.chrome, .passwords), + (.chromium, .passwords), + (.coccoc, .passwords), + (.edge, .passwords), + (.vivaldi, .passwords), + (.opera, .passwords), + (.operaGX, .passwords): + + 1; "Open **\(source.importSourceName)**" + 2; "In a fresh tab, click \(Image(.menuVertical16)) then **\(source == .chrome ? "Google " : "")Password Manager → Settings**" + 3; "Find “Export Passwords” and click **Download File**" + 4; "Save the passwords file someplace you can find it (e.g. Desktop)" + 5; .button("Select Passwords CSV File…") + + case (.yandex, .passwords): + 1; "Open **Yandex**" + 2; "Click \(Image(.menuHamburger16)) to open the application menu then click **Passwords and cards**" + 3; "Click \(Image(.menuVertical16)) then **Export passwords**" + 4; "Choose **To a text file (not secure)** and click **Export**" + 5; "Save the passwords file someplace you can find it (e.g. Desktop)" + 6; .button("Select Passwords CSV File…") + + case (.brave, .bookmarks), + (.chrome, .bookmarks), + (.chromium, .bookmarks), + (.coccoc, .bookmarks), + (.edge, .bookmarks), + (.vivaldi, .bookmarks), + (.opera, .bookmarks), + (.operaGX, .bookmarks): + 1; "Open **\(source.importSourceName)**" + 2; "Use the Menu Bar to select **Bookmarks → Bookmark Manager**" + 3; "Click \(Image(.menuVertical16)) then **Export Bookmarks**" + 4; "Save the file someplace you can find it (e.g., Desktop)" + 5; .button("Select Bookmarks HTML File…") + + case (.yandex, .bookmarks): + 1; "Open **\(source.importSourceName)**" + 2; "Use the Menu Bar to select **Favorites → Bookmark Manager**" + 3; "Click \(Image(.menuVertical16)) then **Export bookmarks to HTML file**" + 4; "Save the file someplace you can find it (e.g., Desktop)" + 5; .button("Select Bookmarks HTML File…") + case (.safari, .passwords), (.safariTechnologyPreview, .passwords): + 1; "Open **Safari**" + 2; "Select **File → Export → Passwords**" + 3; "Save the passwords file someplace you can find it (e.g. Desktop)" + 4; .button("Select Passwords CSV File…") + + case (.safari, .bookmarks), (.safariTechnologyPreview, .bookmarks): + 1; "Open **Safari**" + 2; "Select **File → Export → Bookmarks**" + 3; "Save the passwords file someplace you can find it (e.g. Desktop)" + 4; .button("Select Bookmarks HTML File…") + + case (.firefox, .passwords): + 1; "Open **\(source.importSourceName)**" + 2; "Click \(Image(.menuHamburger16)) to open the application menu then click **Passwords**" + 3; "Click \(Image(.menuVertical16)) then **Export Logins…**" + 4; "Save the passwords file someplace you can find it (e.g. Desktop)" + 5; .button("Select Passwords CSV File…") + + case (.firefox, .bookmarks), (.tor, .bookmarks): + 1; "Open **\(source.importSourceName)**" + 2; "Use the Menu Bar to select **Bookmarks → Manage Bookmarks**" + 3; "Click \(Image(.importExport16)) then **Export bookmarks to HTML…**" + 4; "Save the file someplace you can find it (e.g., Desktop)" + 5; .button("Select Bookmarks HTML File…") + + case (.onePassword8, .passwords): + 1; "Open and unlock **\(source.importSourceName)**" + 2; "Select **File → Export** from the Menu Bar and choose the account you want to export" + 3; "Enter your 1Password account password" + 4; "Select the File Format: **CSV (Logins and Passwords only)**" + 5; "Click Export Data and save the file someplace you can find it (e.g. Desktop)" + 6; .button("Select 1Password CSV File…") + case (.onePassword7, .passwords): + 1; "Open and unlock **\(source.importSourceName)**" + 2; "Select the vault you want to Export (You cannot export from “All Vaults.”)" + 3; "Select **File → Export → All Items** from the Menu Bar" + 4; "Enter your 1Password master or account password" + 5; "Select the File Format: **iCloud Keychain (.csv)**" + 6; "Save the passwords file someplace you can find it (e.g. Desktop)" + 7; .button("Select 1Password CSV File…") + case (.bitwarden, .passwords): + 1; "Open and unlock **\(source.importSourceName)**" + 2; "Select **File → Export vault** from the Menu Bar" + 3; "Select the File Format: **.csv**" + 4; "Enter your Bitwarden Master password" + 5; "Click \(Image(systemName: "square.and.arrow.down")) and save the file someplace you can find it (e.g. Desktop)" + 6; .button("Select Bitwarden CSV File…") + + case (.lastPass, .passwords): + 1; "Click on the **\(source.importSourceName)** icon in your browser and enter your master password" + 2; "Select **Open My Vault**" + 3; "From the sidebar select **Advanced Options → Export**" + 4; "Enter your LastPass master password" + 5; "Select the File Format: Comma Delimited Text (.csv)" + 6; .button("Select LastPass CSV File…") + case (.csv, .passwords): + """ + The CSV importer will try to match column headers to their position. + If there is no header, it supports two formats: + """ + 1; "URL, Username, Password" + 2; "Title, URL, Username, Password"; + + .button("Select Passwords CSV File…") + + case (.bookmarksHTML, .bookmarks): + 1; "Open your old browser" + 2; "Click \(Image(.menuHamburger16)) then select **Bookmarks → Bookmark Manager**" + 3; "Click \(Image(.menuVertical16)) then **Export bookmarks to HTML…**" + 4; "Save the file someplace you can find it (e.g., Desktop)" + 5; .button("Select Bookmarks HTML File…") + + case (.bookmarksHTML, .passwords), + (.tor, .passwords), + (.onePassword7, .bookmarks), + (.onePassword8, .bookmarks), + (.bitwarden, .bookmarks), + (.lastPass, .bookmarks), + (.csv, .bookmarks): + { + assertionFailure("Invalid source/dataType") + return "" + }() + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + { + switch dataType { + case .bookmarks: + Text("Import Bookmarks") + case .passwords: + Text("Import Passwords") + } + }().font(.headline) + + ForEach(instructions.indices, id: \.self) { i in + HStack(spacing: 4) { + ForEach(instructions[i].indices, id: \.self) { j in + switch instructions[i][j] { + case .number(let number): + CircleNumberView(number: number) + case .text(let localizedStringKey): + Text(localizedStringKey) + case .button(let localizedTitleKey): + Button(localizedTitleKey, action: action) + } + } + } + } + } + } + +} + +struct CircleNumberView: View { + + let number: Int + + var body: some View { + Circle() + .fill(.globalBackground) + .frame(width: 20, height: 20) + .overlay( + Text("\(number)") + .foregroundColor(.onboardingActionButton) + .font(.headline) + ) + } + +} + +// MARK: - Preview + +#Preview { + FileImportView(source: .bitwarden, dataType: .passwords) + .frame(width: 512 - 20) + +} + +// MARK: - instructions builder helper + +private enum FileImportInstructionsItem { + case number(Int) + case text(LocalizedStringKey) + case button(LocalizedStringKey) +} + +@resultBuilder +private struct FileImportInstructionsBuilder { + static func buildBlock(_ components: [FileImportInstructionsItem]...) -> [FileImportInstructionsItem] { + return components.flatMap { $0 } + } + + static func buildOptional(_ components: [FileImportInstructionsItem]?) -> [FileImportInstructionsItem] { + return components ?? [] + } + + static func buildEither(first component: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { + component + } + + static func buildEither(second component: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { + component + } + + static func buildLimitedAvailability(_ component: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { + component + } + + static func buildArray(_ components: [[FileImportInstructionsItem]]) -> [FileImportInstructionsItem] { + components.flatMap { $0 } + } + + static func buildExpression(_ expression: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { + return expression + } + + static func buildExpression(_ value: Int) -> [FileImportInstructionsItem] { + return [.number(value)] + } + + static func buildExpression(_ value: LocalizedStringKey) -> [FileImportInstructionsItem] { + return [.text(value)] + } + + static func buildExpression(_ value: FileImportInstructionsItem) -> [FileImportInstructionsItem] { + return [value] + } + + static func buildExpression(_ expression: Void) -> [FileImportInstructionsItem] { + return [] + } + +} + +private func buildInstructions(@FileImportInstructionsBuilder builder: () -> [FileImportInstructionsItem]) -> [[FileImportInstructionsItem]] { + let items = builder() + + // zip [1, "text 1", 2, "text 2", "text 3"] to [[1, "text 1"], [2, "text 2"], ["text 3"]] + var result: [[FileImportInstructionsItem]] = [] + var currentNumber: Int? = nil + + for item in items { + switch item { + case .number(let num): + currentNumber = num + case .text, .button: + if let currentNumber { + result.append([.number(currentNumber), item]) + } else { + result.append([item]) + } + currentNumber = nil + } + } + + return result +} diff --git a/DuckDuckGo/DataImport/View/FileImportViewController.swift b/DuckDuckGo/DataImport/View/FileImportViewController.swift deleted file mode 100644 index b2b40f713e..0000000000 --- a/DuckDuckGo/DataImport/View/FileImportViewController.swift +++ /dev/null @@ -1,257 +0,0 @@ -// -// FileImportViewController.swift -// -// Copyright © 2021 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import AppKit - -protocol FileImportViewControllerDelegate: AnyObject { - - func fileImportViewController(_ viewController: FileImportViewController, didSelectCSVFileWithURL: URL?) - func totalValidLogins(in fileURL: URL) -> Int? - - func fileImportViewController(_ viewController: FileImportViewController, didSelectBookmarksFileWithURL: URL?) - func totalValidBookmarks(in fileURL: URL) -> Int? -} - -final class FileImportViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "FileImportViewController" - static let wideStackViewSpacing: CGFloat = 20 - static let narrowStackViewSpacing: CGFloat = 12 - } - - static func create(importSource: DataImport.Source) -> FileImportViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - let controller: FileImportViewController = storyboard.instantiateController(identifier: Constants.identifier) - controller.importSource = importSource - return controller - } - - @IBOutlet var stackView: NSStackView! - - @IBOutlet var descriptionLabel: NSTextField! - @IBOutlet var selectFileButton: NSButton! - - @IBOutlet var selectedFileContainer: NSView! - @IBOutlet var selectedFileLabel: NSTextField! - @IBOutlet var totalValidLoginsLabel: NSTextField! - - @IBOutlet var safariInfoView: NSView! - @IBOutlet var yandexInfoView: NSView! - @IBOutlet var lastPassInfoView: NSView! - @IBOutlet var onePassword7InfoView: NSView! - @IBOutlet var onePassword8InfoView: NSView! - @IBOutlet var bitwardenInfoView: NSView! - - @IBOutlet var safariSettingsTextField: NSTextField! - - var importSource: DataImport.Source = .csv { - didSet { - if oldValue != importSource { - currentImportState = .awaitingFileSelection - } - - renderCurrentState() - } - } - weak var delegate: FileImportViewControllerDelegate? - - // MARK: - View State - - private enum ImportState { - case awaitingFileSelection - case selectedValidFile(fileURL: URL) - case selectedInvalidFile - } - - private var currentImportState: ImportState = .awaitingFileSelection - - // MARK: - View Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - setUpSafariImportInstructions() - renderCurrentState() - } - - private func setUpSafariImportInstructions() { - let safariSettingsTitle: String = { - if #available(macOS 13.0, *) { - return UserText.safariSettings - } else { - return UserText.safariPreferences - } - }() - - safariSettingsTextField.stringValue = "Safari → \(safariSettingsTitle)" - } - - private func renderCurrentState() { - guard isViewLoaded else { return } - render(state: currentImportState) - } - - // swiftlint:disable:next function_body_length - private func renderAwaitingFileSelectionState() { - switch importSource { - case .safari, .safariTechnologyPreview: - descriptionLabel.isHidden = true - safariInfoView.isHidden = false - yandexInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - bitwardenInfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectBrowserCSVFile - case .yandex: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - yandexInfoView.isHidden = false - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - bitwardenInfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectBrowserCSVFile - case .onePassword7: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - yandexInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = false - onePassword8InfoView.isHidden = true - bitwardenInfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelect1PasswordCSVFile - case .onePassword8: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - yandexInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = false - bitwardenInfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelect1PasswordCSVFile - case .bitwarden: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - bitwardenInfoView.isHidden = false - selectFileButton.title = UserText.importLoginsSelectBitwardenCSVFile - case .lastPass: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - yandexInfoView.isHidden = true - lastPassInfoView.isHidden = false - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - bitwardenInfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectLastPassCSVFile - - case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .tor, .vivaldi: - assertionFailure("CSV Import not supported for \(importSource)") - fallthrough - case .csv: - descriptionLabel.isHidden = false - safariInfoView.isHidden = true - yandexInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - bitwardenInfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectCSVFile - case .bookmarksHTML: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - yandexInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - bitwardenInfoView.isHidden = true - selectFileButton.title = UserText.importBookmarksSelectHTMLFile - } - } - - private func render(state: ImportState) { - descriptionLabel.stringValue = UserText.csvImportDescription - - switch state { - case .awaitingFileSelection: - selectedFileContainer.isHidden = true - renderAwaitingFileSelectionState() - case .selectedValidFile(let fileURL): - // In case the import source has changed, the file selection state's info view needs to be refreshed. - renderAwaitingFileSelectionState() - - selectedFileContainer.isHidden = false - selectedFileLabel.stringValue = fileURL.path - if importSource == .bookmarksHTML { - let totalBookmarksToImport = self.delegate?.totalValidBookmarks(in: fileURL) ?? 0 - selectFileButton.title = UserText.importBookmarksSelectAnotherFile - totalValidLoginsLabel.stringValue = UserText.importingFile(validBookmarks: totalBookmarksToImport) - } else { - let totalLoginsToImport = self.delegate?.totalValidLogins(in: fileURL) ?? 0 - selectFileButton.title = UserText.importLoginsSelectAnotherFile - totalValidLoginsLabel.stringValue = UserText.importingFile(validLogins: totalLoginsToImport) - } - case .selectedInvalidFile: - selectedFileLabel.isHidden = false - if importSource == .bookmarksHTML { - selectedFileLabel.stringValue = UserText.importBookmarksFailedToReadHTMLFile - selectFileButton.title = UserText.importBookmarksSelectHTMLFile - } else { - selectedFileLabel.stringValue = UserText.importLoginsFailedToReadCSVFile - selectFileButton.title = UserText.importLoginsSelectCSVFile - } - } - } - - @IBAction func selectFileButtonClicked(_ sender: Any) { - let fileExtension: String = { - switch importSource { - case .bookmarksHTML: - return "html" - default: - return "csv" - } - }() - let panel = NSOpenPanel.filePanel(allowedExtension: fileExtension) - let result = panel.runModal() - - if result == .OK { - if let selectedURL = panel.url { - currentImportState = .selectedValidFile(fileURL: selectedURL) - switch importSource { - case .bookmarksHTML: - delegate?.fileImportViewController(self, didSelectBookmarksFileWithURL: selectedURL) - case .csv, .bitwarden, .onePassword8, .onePassword7, .lastPass, .safari, .safariTechnologyPreview, .yandex: - delegate?.fileImportViewController(self, didSelectCSVFileWithURL: selectedURL) - case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .tor, .vivaldi: - break - } - } else { - currentImportState = .selectedInvalidFile - delegate?.fileImportViewController(self, didSelectCSVFileWithURL: nil) - } - } - - renderCurrentState() - } - -} diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift new file mode 100644 index 0000000000..af35da06d0 --- /dev/null +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -0,0 +1,145 @@ +// +// ReportFeedbackView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import SwiftUI + +struct ReportFeedbackView: View { + + @Binding var text: String + + let retryNumber: Int + var title: LocalizedStringKey { + if retryNumber <= 1 { + "Please submit a report to help us fix the issue." + } else { + "That didn’t work either. Please submit a report to help us fix the issue." + } + } + + // TODO: pass these from main view + var osVersion: String = "\(ProcessInfo.processInfo.operatingSystemVersion)" + var appVersion: String = "\(AppVersion.shared.versionNumber)" + var importSource: DataImport.Source + + var importSourceVersion: String? + var error: LocalizedError + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + Text(title) + .font(.headline) + Spacer().frame(height: 8) + + VStack(alignment: .leading, spacing: 12) { + Text(""" + The following information will be sent to DuckDuckGo. No personally identifiable information will be sent. + """) + + InfoItemView("macOS version", osVersion) + InfoItemView("DuckDuckGo browser version", appVersion) + InfoItemView("The version of the browser you are trying to import from", + importSource.importSourceName + (importSourceVersion.map { " \($0)" } ?? "")) + InfoItemView("Error message & code", error.localizedDescription) + } + Spacer().frame(height: 24) + + ZStack(alignment: .top) { + EditableTextView(text: $text, + font: NSFont(name: "SF Pro Text", size: 13), + insets: NSSize(width: 11, height: 11)) + .cornerRadius(6) + .frame(height: 114) + .shadow(radius: 1, x: 0, y: 1) + + if text.isEmpty { + HStack { + Text("Add any details that you think may help us fix the problem") + .font(.custom("SF Pro Text", size: 13)) + .foregroundColor(Color(.placeholderTextColor)) + Spacer() + }.padding(EdgeInsets(top: 11, leading: 11, bottom: 0, trailing: 11)) + } + } + } + } + +} + +private struct InfoItemView: View { + + let text: LocalizedStringKey + let data: String + @State private var isPopoverVisible = false + + init(_ text: LocalizedStringKey, _ data: String) { + self.text = text + self.data = data + } + + var body: some View { + HStack(spacing: 6) { + Button { + isPopoverVisible.toggle() + } label: { + Image(.infoLight) + } + .buttonStyle(.borderless) + .popover(isPresented: $isPopoverVisible, arrowEdge: .bottom) { + Text(data).padding() + } + + Text(text) + } + } + +} + +#Preview { + + ReportFeedbackView(text: .constant(""), + retryNumber: 2, + importSource: .safari, + importSourceVersion: UserAgent.safariVersion, + error: { + enum ImportError: DataImportError { + enum OperationType: Int { + case imp + } + + var type: OperationType { .imp } + var source: DataImport.Source { .chrome } + var action: DataImportAction { .generic } + var underlyingError: Error? { + if case .err(let err) = self { + return err + } + return nil + } + + static var errorDomain: String { "ReportFeedbackPreviewError" } + + case err(Error) + } + return ImportError.err(CocoaError(.fileReadUnknown)) + }()) + .frame(width: 512 - 20) + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + +} diff --git a/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift b/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift new file mode 100644 index 0000000000..884419d31a --- /dev/null +++ b/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift @@ -0,0 +1,54 @@ +// +// RequestFilePermissionView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct RequestFilePermissionView: View { + + private let source: DataImport.Source + private let url: URL + private let requestDataDirectoryPermission: @MainActor (URL) -> URL? + private let callback: @MainActor (URL) -> Void + + init(source: DataImport.Source, url: URL, requestDataDirectoryPermission: @escaping @MainActor (URL) -> URL?, callback: @escaping @MainActor (URL) -> Void) { + self.source = source + self.url = url + self.requestDataDirectoryPermission = requestDataDirectoryPermission + self.callback = callback + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("DuckDuckGo needs your permission to read the \(source.importSourceName) bookmarks file. Select the \(source.importSourceName) folder to import bookmarks.") + Button("Select \(source.importSourceName) Folder…") { + if let url = requestDataDirectoryPermission(url) { + callback(url) + } + } + } + } + +} + +#Preview { + RequestFilePermissionView(source: .safari, url: URL(fileURLWithPath: "/file/path")) { + $0 + } callback: { _ in } + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + .frame(width: 512) +} diff --git a/DuckDuckGo/DataImport/View/RequestFilePermissionViewController.swift b/DuckDuckGo/DataImport/View/RequestFilePermissionViewController.swift deleted file mode 100644 index 87ace08729..0000000000 --- a/DuckDuckGo/DataImport/View/RequestFilePermissionViewController.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// RequestFilePermissionViewController.swift -// -// Copyright © 2021 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import AppKit - -protocol RequestFilePermissionViewControllerDelegate: AnyObject { - - func requestFilePermissionViewControllerDidReceivePermission(_ viewController: RequestFilePermissionViewController) - -} - -final class RequestFilePermissionViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "RequestFilePermissionViewController" - } - - static func create(importSource: DataImport.Source, permissionsRequired: [DataImport.DataType], permissionAuthorization: DataDirectoryPermissionAuthorization) -> RequestFilePermissionViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - - return storyboard.instantiateController(identifier: Constants.identifier) { coder in - RequestFilePermissionViewController(coder: coder, - importSource: importSource, - permissionsRequired: permissionsRequired, - permissionAuthorization: permissionAuthorization) - } - } - - @IBOutlet var descriptionLabel: NSTextField! - @IBOutlet var requestPermissionButton: NSButton! - - weak var delegate: RequestFilePermissionViewControllerDelegate? - - private let importSource: DataImport.Source - private let permissionsRequired: [DataImport.DataType] - private let permissionAuthorization: DataDirectoryPermissionAuthorization - - init?(coder: NSCoder, importSource: DataImport.Source, permissionsRequired: [DataImport.DataType], permissionAuthorization: DataDirectoryPermissionAuthorization) { - self.importSource = importSource - self.permissionsRequired = permissionsRequired - self.permissionAuthorization = permissionAuthorization - - super.init(coder: coder) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - descriptionLabel.stringValue = UserText.bookmarkImportSafariPermissionDescription - requestPermissionButton.title = UserText.bookmarkImportSafariRequestPermissionButtonTitle - } - - @IBAction private func presentBookmarksOpenPanel(_ sender: AnyObject) { - if permissionAuthorization.requestDataDirectoryPermission() != nil, - permissionAuthorization.canReadBookmarksFile() { - - delegate?.requestFilePermissionViewControllerDidReceivePermission(self) - } - } - -} diff --git a/DuckDuckGo/Feedback/Model/Feedback.swift b/DuckDuckGo/Feedback/Model/Feedback.swift index bc44cefab4..a4998aa897 100644 --- a/DuckDuckGo/Feedback/Model/Feedback.swift +++ b/DuckDuckGo/Feedback/Model/Feedback.swift @@ -22,9 +22,13 @@ import QuartzCore struct Feedback { enum Category { + case generalFeedback + case designFeedback case bug case featureRequest case other + case usability + case dataImport } let category: Category diff --git a/DuckDuckGo/Feedback/Model/FeedbackSender.swift b/DuckDuckGo/Feedback/Model/FeedbackSender.swift index 1aa70afd15..4c097a9777 100644 --- a/DuckDuckGo/Feedback/Model/FeedbackSender.swift +++ b/DuckDuckGo/Feedback/Model/FeedbackSender.swift @@ -54,9 +54,13 @@ fileprivate extension Feedback.Category { var asanaId: String { switch self { - case .bug: return "1199184518165816" - case .featureRequest: return "1199184518165815" - case .other: return "1200574389728916" + case .generalFeedback: "1199184518165814" + case .designFeedback: "1199214127353569" + case .bug: "1199184518165816" + case .featureRequest: "1199184518165815" + case .other: "1200574389728916" + case .usability: "1204135764912065" + case .dataImport: "1205975547451886" } } diff --git a/DuckDuckGo/Feedback/View/FeedbackViewController.swift b/DuckDuckGo/Feedback/View/FeedbackViewController.swift index 4043a88c7b..5b9a1abf36 100644 --- a/DuckDuckGo/Feedback/View/FeedbackViewController.swift +++ b/DuckDuckGo/Feedback/View/FeedbackViewController.swift @@ -260,6 +260,9 @@ final class FeedbackViewController: NSViewController { browserFeedbackDescriptionLabel.stringValue = UserText.feedbackFeatureRequestDescription case .other: browserFeedbackDescriptionLabel.stringValue = UserText.feedbackOtherDescription + case .generalFeedback, .designFeedback, .usability, .dataImport: + assertionFailure("unexpected flow") + browserFeedbackDescriptionLabel.stringValue = "\(category)" } } diff --git a/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift b/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift index 026e1582f0..93002ae88c 100644 --- a/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift +++ b/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift @@ -47,8 +47,10 @@ final class BookmarksAndPasswordsImportStatusProvider: DataImportStatusProviding } return successfulImportHappened! } + + @MainActor func showImportWindow(completion: (() -> Void)?) { - DataImportViewController.show(completion: completion) + DataImportView.show(completion: completion) } // It only cover the case in which the user has imported bookmar AFTER already having some bookmarks diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 709c304cdb..666763cecf 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -159,7 +159,7 @@ extension AppDelegate { } @objc func openImportBrowserDataWindow(_ sender: Any?) { - DataImportViewController.show() + DataImportView.show() } @objc func openExportLogins(_ sender: Any?) { diff --git a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift index 00633f870c..c19a2bff39 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift @@ -28,7 +28,7 @@ final class AddressBarViewController: NSViewController { @IBOutlet var activeBackgroundView: NSView! @IBOutlet var activeOuterBorderView: NSView! @IBOutlet var activeBackgroundViewWithSuggestions: NSView! - @IBOutlet var progressIndicator: ProgressView! + @IBOutlet var progressIndicator: LoadingProgressView! @IBOutlet var passiveTextFieldMinXConstraint: NSLayoutConstraint! @IBOutlet var activeTextFieldMinXConstraint: NSLayoutConstraint! private static let defaultActiveTextFieldMinX: CGFloat = 40 diff --git a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard index c13932d168..c6e7f79b1d 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard +++ b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard @@ -389,7 +389,7 @@ - + diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index a7828f9bca..8993104460 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -946,7 +946,7 @@ extension NavigationBarViewController: OptionsButtonMenuDelegate { } func optionsButtonMenuRequestedBookmarkImportInterface(_ menu: NSMenu) { - DataImportViewController.show() + DataImportView.show() } func optionsButtonMenuRequestedBookmarkExportInterface(_ menu: NSMenu) { diff --git a/DuckDuckGo/SecureVault/View/EditableTextView.swift b/DuckDuckGo/SecureVault/View/EditableTextView.swift index c91abf0bfb..543e173887 100644 --- a/DuckDuckGo/SecureVault/View/EditableTextView.swift +++ b/DuckDuckGo/SecureVault/View/EditableTextView.swift @@ -25,10 +25,11 @@ struct EditableTextView: NSViewRepresentable { var isEditable: Bool = true var font: NSFont? = .systemFont(ofSize: 14, weight: .regular) - var onEditingChanged: () -> Void = {} + var onEditingChanged: () -> Void = {} var onCommit: () -> Void = {} var onTextChange: (String) -> Void = { _ in } var maxLength: Int? + var insets: NSSize? func makeCoordinator() -> Coordinator { Coordinator(self) @@ -38,7 +39,8 @@ struct EditableTextView: NSViewRepresentable { let textView = CustomTextView( text: text, isEditable: isEditable, - font: font + font: font, + insets: insets ) textView.delegate = context.coordinator return textView @@ -92,8 +94,9 @@ extension EditableTextView { final class CustomTextView: NSView { - private var isEditable: Bool - private var font: NSFont? + private let isEditable: Bool + private let font: NSFont? + private let insets: NSSize? weak var delegate: NSTextViewDelegate? var text: String { @@ -149,16 +152,20 @@ final class CustomTextView: NSView { textView.minSize = NSSize(width: 0, height: contentSize.height) textView.textColor = NSColor.labelColor textView.allowsUndo = true + if let insets { + textView.textContainerInset = insets + } return textView }() // MARK: - Init - init(text: String, isEditable: Bool, font: NSFont?) { + init(text: String, isEditable: Bool, font: NSFont?, insets: NSSize?) { self.font = font self.isEditable = isEditable self.text = text + self.insets = insets super.init(frame: .zero) } diff --git a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift index 9b26bbae9e..8c0c41fb07 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift +++ b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift @@ -285,7 +285,7 @@ final class PasswordManagementViewController: NSViewController { @IBAction func onImportClicked(_ sender: NSButton) { self.dismiss() - DataImportViewController.show() + DataImportView.show() } @IBAction func deviceAuthenticationRequested(_ sender: NSButton) { diff --git a/DuckDuckGo/Sharing/SharingMenu.swift b/DuckDuckGo/Sharing/SharingMenu.swift index 63d571972c..c3a6c1ec12 100644 --- a/DuckDuckGo/Sharing/SharingMenu.swift +++ b/DuckDuckGo/Sharing/SharingMenu.swift @@ -40,7 +40,7 @@ final class SharingMenu: NSMenu { self.items = services.map { service in NSMenuItem(service: service, target: self, action: #selector(sharingItemSelected)) } + [ - NSMenuItem(title: UserText.moreMenuItem, action: #selector(openSharingPreferences), target: self).withImage(.more) + NSMenuItem(title: UserText.moreMenuItem, action: #selector(openSharingPreferences), target: self).withImage(.sharedMoreMenu) ] } @@ -173,7 +173,7 @@ private extension NSMenuItem { private extension NSImage { - static var more: NSImage? { + static var sharedMoreMenu: NSImage? { let sharedMoreMenuImageSelector = NSSelectorFromString("sharedMoreMenuImage") guard NSSharingServicePicker.responds(to: sharedMoreMenuImageSelector) else { return nil } return NSSharingServicePicker.perform(sharedMoreMenuImageSelector)?.takeUnretainedValue() as? NSImage diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index b122d9dcca..f4274f2845 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -931,7 +931,7 @@ extension BrowserTabViewController: BrowserTabSelectionDelegate { extension BrowserTabViewController: OnboardingDelegate { func onboardingDidRequestImportData(completion: @escaping () -> Void) { - DataImportViewController.show(completion: completion) + DataImportView.show(completion: completion) } func onboardingDidRequestSetDefault(completion: @escaping () -> Void) { diff --git a/DuckDuckGo/YoutubePlayer/DuckPlayer.swift b/DuckDuckGo/YoutubePlayer/DuckPlayer.swift index 309d507d96..3e8ceb959e 100644 --- a/DuckDuckGo/YoutubePlayer/DuckPlayer.swift +++ b/DuckDuckGo/YoutubePlayer/DuckPlayer.swift @@ -24,10 +24,6 @@ import Navigation import WebKit import UserScript -extension NSImage { - static let duckPlayer: NSImage = #imageLiteral(resourceName: "DuckPlayer") -} - enum DuckPlayerMode: Equatable, Codable { case enabled, alwaysAsk, disabled diff --git a/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift b/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift index e3573c717b..0931b16ace 100644 --- a/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift +++ b/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift @@ -88,9 +88,9 @@ final class BookmarkStoreMock: BookmarkStore { } var importBookmarksCalled = false - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult { + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary { importBookmarksCalled = true - return BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + return BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) } var canMoveObjectWithUUIDCalled = false diff --git a/UnitTests/Common/Progress/ProgressEstimationTests.swift b/UnitTests/Common/Progress/ProgressEstimationTests.swift index a55d8317e1..0cf8c562c7 100644 --- a/UnitTests/Common/Progress/ProgressEstimationTests.swift +++ b/UnitTests/Common/Progress/ProgressEstimationTests.swift @@ -20,7 +20,7 @@ import XCTest @testable import DuckDuckGo_Privacy_Browser final class ProgressEstimationTests: XCTestCase { - typealias Event = ProgressView.ProgressEvent + typealias Event = LoadingProgressView.ProgressEvent let milestones = [ Event(progress: 0.25, interval: 0.0), @@ -34,7 +34,7 @@ final class ProgressEstimationTests: XCTestCase { func testWhenProgressIsStartedEstimationIsInitial() { let event = Event.nextStep(for: 0.0, lastProgressEvent: nil, milestones: milestones) - XCTAssertEqual(event, Event(progress: milestones[0].progress, interval: ProgressView.Constants.animationDuration)) + XCTAssertEqual(event, Event(progress: milestones[0].progress, interval: LoadingProgressView.Constants.animationDuration)) } func testWhenProgressIsFinishedThenNoNextStep() { @@ -73,7 +73,7 @@ final class ProgressEstimationTests: XCTestCase { func testWhenProgressMovesTooFastEstimationIsNotTooShort() { let event = Event.nextStep(for: 0.85, lastProgressEvent: Event(progress: 0.65, interval: 18.0 / 11.0), milestones: milestones) - XCTAssertEqual(event, Event(progress: 1.0, interval: milestones.last!.interval * ProgressView.Constants.minMultiplier)) + XCTAssertEqual(event, Event(progress: 1.0, interval: milestones.last!.interval * LoadingProgressView.Constants.minMultiplier)) } func testWhenProgressMovesSlowerEstimationIsLonger() { diff --git a/UnitTests/DataImport/BookmarksHTMLImporterTests.swift b/UnitTests/DataImport/BookmarksHTMLImporterTests.swift index cf4732e6b5..dbb3f22d04 100644 --- a/UnitTests/DataImport/BookmarksHTMLImporterTests.swift +++ b/UnitTests/DataImport/BookmarksHTMLImporterTests.swift @@ -50,7 +50,7 @@ final class BookmarksHTMLImporterTests: XCTestCase { func testWhenValidBookmarksFileIsLoadedThenBookmarksImportIsSuccessful() { let importExpectation = expectation(description: "Import Bookmarks") let completionExpectation = expectation(description: "Import Bookmarks Completion") - let expectedImportResult = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + let expectedImportResult = BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) underlyingBookmarkImporter.importBookmarks = { (_, _) in importExpectation.fulfill() @@ -74,7 +74,7 @@ final class BookmarksHTMLImporterTests: XCTestCase { func testWhenInvalidBookmarksFileIsLoadedThenBookmarksImportReturnsFailure() { let completionExpectation = expectation(description: "Import Bookmarks Completion") - let expectedImportResult = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + let expectedImportResult = BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) underlyingBookmarkImporter.importBookmarks = { (_, _) in XCTFail("unexpected import success") diff --git a/UnitTests/DataImport/DataImportMocks.swift b/UnitTests/DataImport/DataImportMocks.swift index 50df6cf6f5..3f9453f41d 100644 --- a/UnitTests/DataImport/DataImportMocks.swift +++ b/UnitTests/DataImport/DataImportMocks.swift @@ -23,8 +23,8 @@ final class MockLoginImporter: LoginImporter { var importedLogins: DataImport.Summary? - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.CompletedLoginsResult { - let summary = DataImport.CompletedLoginsResult(successfulImports: logins.map(\.username), duplicateImports: [], failedImports: []) + func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.LoginsImportSummary { + let summary = DataImport.LoginsImportSummary(successfulImports: logins.map(\.username), duplicateImports: [], failedImports: []) self.importedLogins = .init(bookmarksResult: nil, loginsResult: .completed(summary)) return summary @@ -36,10 +36,10 @@ struct BookmarkImportErrorMock: Error {} struct MockBookmarkImporter: BookmarkImporter { - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult { + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary { return importBookmarks(bookmarks, source) } - var importBookmarks: (ImportedBookmarks, BookmarkImportSource) -> BookmarkImportResult + var importBookmarks: (ImportedBookmarks, BookmarkImportSource) -> BookmarksImportSummary } diff --git a/UnitTests/DataImport/FirefoxDataImporterTests.swift b/UnitTests/DataImport/FirefoxDataImporterTests.swift index ca4bc0117a..2bd64ff320 100644 --- a/UnitTests/DataImport/FirefoxDataImporterTests.swift +++ b/UnitTests/DataImport/FirefoxDataImporterTests.swift @@ -23,34 +23,19 @@ import XCTest @MainActor class FirefoxDataImporterTests: XCTestCase { - func testWhenImportingWithoutAnyDataTypes_ThenSummaryIsEmpty() async { - let loginImporter = MockLoginImporter() - let faviconManager = FaviconManagerMock() - let bookmarkImporter = MockBookmarkImporter(importBookmarks: { _, _ in .init(successful: 0, duplicates: 0, failed: 0) }) - let importer = FirefoxDataImporter(loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) - - let summary = await importer.importData(types: [], from: .init(browser: .firefox, profileURL: resourceURL())) - - if case let .success(summary) = summary { - XCTAssert(summary.isEmpty) - } else { - XCTFail("Received failure unexpectedly") - } - } - func testWhenImportingBookmarks_AndBookmarkImportSucceeds_ThenSummaryIsPopulated() async { let loginImporter = MockLoginImporter() let faviconManager = FaviconManagerMock() let bookmarkImporter = MockBookmarkImporter(importBookmarks: { _, _ in .init(successful: 1, duplicates: 2, failed: 3) }) - let importer = FirefoxDataImporter(loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) + let importer = FirefoxDataImporter(profile: .init(browser: .firefox, profileURL: resourceURL()), loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) - let summary = await importer.importData(types: [.bookmarks], from: .init(browser: .firefox, profileURL: resourceURL())) + let result = await importer.importData(types: [.bookmarks]) - if case let .success(summary) = summary { - XCTAssertEqual(summary.bookmarksResult?.successful, 1) - XCTAssertEqual(summary.bookmarksResult?.duplicates, 2) - XCTAssertEqual(summary.bookmarksResult?.failed, 3) - XCTAssertNil(summary.loginsResult) + XCTAssertNil(result.logins) + if case let .success(bookmarks) = result.bookmarks { + XCTAssertEqual(bookmarks.successful, 1) + XCTAssertEqual(bookmarks.duplicates, 2) + XCTAssertEqual(bookmarks.failed, 3) } else { XCTFail("Received populated summary unexpectedly") } @@ -63,9 +48,9 @@ class FirefoxDataImporterTests: XCTestCase { } extension FirefoxDataImporter { - func importData(types: [DataImport.DataType], from profile: DataImport.BrowserProfile?) async -> DataImportResult { + func importData(types: Set) async -> DataImport.Summary { return await withCheckedContinuation { continuation in - importData(types: types, from: profile) { result in + importData(types: types) { result in continuation.resume(returning: result) } } diff --git a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift index 886517c90f..f81f7ba301 100644 --- a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift +++ b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift @@ -81,8 +81,8 @@ class MockBookmarkManager: BookmarkManager { func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) {} - func importBookmarks(_ bookmarks: DuckDuckGo_Privacy_Browser.ImportedBookmarks, source: DuckDuckGo_Privacy_Browser.BookmarkImportSource) -> DuckDuckGo_Privacy_Browser.BookmarkImportResult { - BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + func importBookmarks(_ bookmarks: DuckDuckGo_Privacy_Browser.ImportedBookmarks, source: DuckDuckGo_Privacy_Browser.BookmarkImportSource) -> DuckDuckGo_Privacy_Browser.BookmarksImportSummary { + BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) } func handleFavoritesAfterDisablingSync() {} From 7a6cb406c753e17e2de15d15f16b782b513c4651 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 17 Nov 2023 18:17:24 +0600 Subject: [PATCH 16/83] show summary if all sources succeeded; disable Select file button when in progress --- .../Logins/Chromium/YandexDataImporter.swift | 4 ++++ .../DataImport/Model/DataImportViewModel.swift | 12 ++++++------ DuckDuckGo/DataImport/View/DataImportView.swift | 2 +- DuckDuckGo/DataImport/View/FileImportView.swift | 8 ++++++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift index 734b190f04..94a2809049 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift @@ -37,4 +37,8 @@ final class YandexDataImporter: ChromiumDataImporter { return super.importData(types: types.filter { $0 != .passwords }) } + override func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + false + } + } diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 76f68c7160..f5407ce314 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -150,6 +150,7 @@ struct DataImportViewModel { } if self.areAllSelectedDataTypesSuccessfullyImported { + successfulImportHappened = true NotificationCenter.default.post(name: .dataImportComplete, object: nil) } @@ -217,12 +218,7 @@ struct DataImportViewModel { break } // all done - for (_, result) in summary { - if case .failure = result { - return .feedback() - } - } - return .summary + return areAllSelectedDataTypesSuccessfullyImported ? .summary : .feedback() } /// Skip button press @@ -513,6 +509,10 @@ extension DataImportViewModel { false } + var isSelectFileButtonDisabled: Bool { + importTask != nil + } + @MainActor var buttons: [(type: ButtonType, isDisabled: Bool)] { [ diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index b1ba95a136..dbf20b7494 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -113,7 +113,7 @@ struct DataImportView: View { } // manual file import instructions for CSV/HTML - FileImportView(source: viewModel.importSource, dataType: dataType) { + FileImportView(source: viewModel.importSource, dataType: dataType, isButtonDisabled: viewModel.isSelectFileButtonDisabled) { viewModel.selectFile() } diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index f7fbbf9ec8..f1efe09f6e 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -25,11 +25,14 @@ struct FileImportView: View { let action: (() -> Void) private let instructions: [[FileImportInstructionsItem]] - init(source: DataImport.Source, dataType: DataImport.DataType, action: (() -> Void)? = nil) { + private var isButtonDisabled: Bool + + init(source: DataImport.Source, dataType: DataImport.DataType, isButtonDisabled: Bool, action: (() -> Void)? = nil) { self.source = source self.dataType = dataType self.action = action ?? {} self.instructions = Self.instructions(for: source, dataType: dataType) + self.isButtonDisabled = isButtonDisabled } // swiftlint:disable:next function_body_length @@ -188,6 +191,7 @@ struct FileImportView: View { Text(localizedStringKey) case .button(let localizedTitleKey): Button(localizedTitleKey, action: action) + .disabled(isButtonDisabled) } } } @@ -217,7 +221,7 @@ struct CircleNumberView: View { // MARK: - Preview #Preview { - FileImportView(source: .bitwarden, dataType: .passwords) + FileImportView(source: .bitwarden, dataType: .passwords, isButtonDisabled: false) .frame(width: 512 - 20) } From ea33df90d3f20e9b06cb5c11d1b76285b1e1260a Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 17 Nov 2023 18:56:28 +0600 Subject: [PATCH 17/83] fix CSV, 1Password 8 selection in picker; Remove Skip button for file-import-only sources --- .../Model/DataImportSourceViewModel.swift | 12 ++++++++- .../Model/DataImportViewModel.swift | 4 ++- .../View/DataImportSourcePicker.swift | 25 +++++++++---------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift index 74f9d35137..0ead43e06b 100644 --- a/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift @@ -26,8 +26,18 @@ struct DataImportSourceViewModel { let onSelectedSourceChanged: (DataImport.Source) -> Void init(importSources: [DataImport.Source]? = nil, selectedSource: DataImport.Source, onSelectedSourceChanged: @escaping (DataImport.Source) -> Void) { + var importSources: [DataImport.Source?] = (importSources ?? DataImport.Source.allCases.filter(\.canImportData)) + + // The CSV row is at the bottom of the picker, and requires a separator above it, but only if the item array isn't + // empty (which would happen if there are no valid sources). + for source in [DataImport.Source.onePassword8, .csv] { + if let idx = importSources.lastIndex(of: source), idx > 0 { + // separator + importSources.insert(nil, at: idx) + } + } + self.importSources = importSources - self.importSources = importSources ?? DataImport.Source.allCases.filter(\.canImportData) assert(!self.importSources.isEmpty) self.selectedSourceIndex = self.importSources.firstIndex(of: selectedSource) ?? 0 diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index f5407ce314..42e489fbdc 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -457,7 +457,9 @@ extension DataImportViewModel { return .initiateImport case .fileImport: - if case .summary = nextScreen(skip: true) { + if case .fileImport = importSource.initialScreen { + return nil + } else if case .summary = nextScreen(skip: true) { return secondaryButton == .back ? .cancel : nil } return .skip diff --git a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift index 8f1865710d..ac34184231 100644 --- a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift @@ -23,7 +23,7 @@ struct DataImportSourcePicker: View { @State private var viewModel: DataImportSourceViewModel - private var importSources: [DataImport.Source] { + private var importSources: [DataImport.Source?] { viewModel.importSources } @@ -35,22 +35,21 @@ struct DataImportSourcePicker: View { var body: some View { Picker(selection: $viewModel.selectedSourceIndex) { ForEach(importSources.indices, id: \.self) { idx in - HStack { - // The CSV row is at the bottom of the picker, and requires a separator above it, but only if the item array isn't - // empty (which would happen if there are no valid sources). - if idx > 0, [.onePassword8, .csv].contains(importSources[idx]) { - Divider() + if let source = importSources[idx] { + HStack { + if let icon = source.importSourceImage?.resized(to: NSSize(width: 16, height: 16)) { + Image(nsImage: icon) + } + Text(source.importSourceName) } - - if let icon = importSources[idx].importSourceImage?.resized(to: NSSize(width: 16, height: 16)) { - Image(nsImage: icon) - } - Text(importSources[idx].importSourceName) + } else { + Divider() } } } label: {} .onChange(of: viewModel.selectedSourceIndex) { idx in - viewModel.onSelectedSourceChanged(importSources[idx]) + guard let importSource = importSources[idx] else { return } + viewModel.onSelectedSourceChanged(importSource) } .pickerStyle(MenuPickerStyle()) } @@ -59,6 +58,6 @@ struct DataImportSourcePicker: View { #Preview { DataImportSourcePicker(selectedSource: .csv) { - print("seiection:", $0) + print("selection:", $0) } } From 795c1dcdfcb844054175a0fcb34f835499a3a158 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 17 Nov 2023 19:07:33 +0600 Subject: [PATCH 18/83] fix build --- DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift index 0ead43e06b..1f85a44b7d 100644 --- a/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift @@ -20,7 +20,7 @@ import Foundation struct DataImportSourceViewModel { - let importSources: [DataImport.Source] + let importSources: [DataImport.Source?] var selectedSourceIndex: Int let onSelectedSourceChanged: (DataImport.Source) -> Void From 278820afe0170f37323d7ec15062f855eb3929c4 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 17 Nov 2023 19:37:49 +0600 Subject: [PATCH 19/83] cleanup --- .../Common/Extensions/StringExtension.swift | 14 ---- DuckDuckGo/Common/Localizables/UserText.swift | 66 ------------------- .../DataImport/View/NSAlert+DataImport.swift | 40 ----------- 3 files changed, 120 deletions(-) diff --git a/DuckDuckGo/Common/Extensions/StringExtension.swift b/DuckDuckGo/Common/Extensions/StringExtension.swift index 5e8f896d7b..53cd3148a2 100644 --- a/DuckDuckGo/Common/Extensions/StringExtension.swift +++ b/DuckDuckGo/Common/Extensions/StringExtension.swift @@ -72,20 +72,6 @@ extension String { (self as NSString).pathExtension } - var abbreviatingWithTildeInPath: String { - if NSApp.isSandboxed { - let homeDirectory = URL.nonSandboxHomeDirectory.path.dropping(suffix: "/") - guard self.hasPrefix(homeDirectory + "/") else { - if self == homeDirectory { - return "~/" - } - return self - } - return "~" + self.dropping(prefix: homeDirectory) - } - return (self as NSString).abbreviatingWithTildeInPath - } - // MARK: - Mutating @inlinable mutating func prepend(_ string: String) { diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index f02dd4052c..285c1e5628 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -504,39 +504,16 @@ struct UserText { static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Logins File", comment: "Title text for the CSV importer") static let importBookmarksHTML = NSLocalizedString("import.bookmarks.html.title", value: "HTML Bookmarks File", comment: "Title text for the HTML Bookmarks importer") - static let importBookmarksFailedToReadHTMLFile = NSLocalizedString("import.bookmarks.failed-to-read-file", value: "Failed to read HTML file", comment: "Error text when importing a HTML file") - - static func importingFile(validBookmarks: Int) -> String { - let localized = NSLocalizedString("import.bookmarks.html.valid-bookmarks", - value: "Contains %@ bookmarks", - comment: "Displays the number of the bookmarks being imported") - return String(format: localized, String(validBookmarks)) - } static let importLoginsPasswords = NSLocalizedString("import.logins.passwords", value: "Passwords", comment: "Title text for the Passwords import option") - static let importLoginsFailedToReadCSVFile = NSLocalizedString("import.logins.failed-to-read-file", value: "Failed to get CSV file URL", comment: "Error text when importing a CSV file") - - static func importingFile(validLogins: Int) -> String { - let localized = NSLocalizedString("import.logins.csv.valid-logins", - value: "Contains %@ valid logins", - comment: "Displays the number of the logins being imported") - return String(format: localized, String(validLogins)) - } - static let initiateImport = NSLocalizedString("import.data.initiate", value: "Import", comment: "Button text for importing data") static let skipImport = NSLocalizedString("import.data.skip", value: "Skip", comment: "Button text to skip a kind of imported data") static let done = NSLocalizedString("import.data.done", value: "Done", comment: "Button text for finishing the data import") static let dataImportFailedTitle = NSLocalizedString("import.data.import-failed.title", value: "Sorry, we weren't able to import your data.", comment: "Alert title when the data import fails") - static let dataImportSubmitFeedback = NSLocalizedString("import.data.submit-feedback", value: "submit feedback", comment: "Link text used in the data import failure alert") - static let dataImportFailedBody = NSLocalizedString("import.data.import-failed.body", - value: "Please submit feedback so we can address this issue.", - comment: "Alert body text used in the data import failure alert") - static let dataImportAlertImport = NSLocalizedString("import.data.alert.import", value: "Import", comment: "Import button for data import alerts") - static let dataImportAlertAccept = NSLocalizedString("import.data.alert.accept", value: "Okay", comment: "Accept button for data import alerts") static let dataImportAlertCancel = NSLocalizedString("import.data.alert.cancel", value: "Cancel", comment: "Cancel button for data import alerts") static func dataImportRequiresPasswordTitle(_ source: DataImport.Source) -> String { @@ -553,41 +530,9 @@ struct UserText { return String(format: localized, source.importSourceName) } - static func dataImportBrowserMustBeClosed(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.close-browser", - value: "Please ensure that %@ is not running before importing data", - comment: "Alert body text when the data import fails due to the browser being open") - return String(format: localized, source.importSourceName) - } - - static func dataImportQuitBrowserTitle(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.quit-browser.title", - value: "Would you like to quit %@ now?", - comment: "Alert title text when prompting to close the browser") - return String(format: localized, source.importSourceName) - } - - static func dataImportQuitBrowserBody(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.quit-browser.body", - value: "You must quit %@ before importing data.", - comment: "Alert body text when prompting to close the browser") - return String(format: localized, source.importSourceName) - } - - static func dataImportQuitBrowserButton(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.quit-browser.accept-button", - value: "Quit %@", - comment: "Accept button text when prompting to close the browser") - return String(format: localized, source.importSourceName) - } - static let bookmarkImportSafariRequestPermissionButtonTitle = NSLocalizedString("import.bookmarks.safari.permission-button.title", value: "Select Safari Folder…", comment: "Text for the Safari data import permission button") - static let bookmarkImportBookmarksBar = NSLocalizedString("import.bookmarks.folder.bookmarks-bar", value: "Bookmarks Bar", comment: "Title text for Bookmarks Bar import folder") - static let bookmarkImportOtherBookmarks = NSLocalizedString("import.bookmarks.folder.other-bookmarks", value: "Other Bookmarks", comment: "Title text for Other Bookmarks import folder") - static let bookmarkImportBookmarks = NSLocalizedString("import.bookmarks.bookmarks", value: "Bookmarks", comment: "Title text for the Bookmarks import option") - static let bookmarkImportBookmarksAndFavorites = NSLocalizedString("import.bookmarks.bookmarks-and-favorites", value: "Bookmarks & Favorites", comment: "Title text for the Bookmarks & Favorites import option") static let openDeveloperTools = NSLocalizedString("main.menu.show.inspector", value: "Open Developer Tools", comment: "Show Web Inspector/Open Developer Tools") static let closeDeveloperTools = NSLocalizedString("main.menu.close.inspector", value: "Close Developer Tools", comment: "Hide Web Inspector/Close Developer Tools") @@ -656,17 +601,6 @@ struct UserText { } } - static let requiresSafari15warning = NSLocalizedString("requires.safari.15.or.later", value: "Requires Safari 15 or later", comment: "Warning label about browser data import feature requiring Safari v.15 or later") - - static func browserDataFileNotFound(atPath path: String) -> String { - let localized = NSLocalizedString("import.unavailable.file.not.found", value: "File not found at %@", comment: "Logins or Bookmarks Import not available because logins or bookmarks file not found at path %@") - return String(format: localized, path) - } - static func browserProfileDataFileNotFound(atPath path: String) -> String { - let localized = NSLocalizedString("import.unavailable.profile.dir.not.found", value: "Profile directory not found at %@", comment: "Logins or Bookmarks Import not available because profile directory is not available at path %@") - return String(format: localized, path) - } - static let moreOrLessCollapse = NSLocalizedString("more.or.less.collapse", value: "Show Less", comment: "For collapsing views to show less.") static let moreOrLessExpand = NSLocalizedString("more.or.less.expand", value: "Show More", comment: "For expanding views to show more.") diff --git a/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift b/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift index 1444e7e8c9..6019cd455e 100644 --- a/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift +++ b/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift @@ -24,35 +24,6 @@ extension NSAlert { return (accessoryView as? NSTextField)?.stringValue } - static func importFailedAlert(linkDelegate: NSTextViewDelegate) -> NSAlert { - let alert = NSAlert() - - let linkText = UserText.dataImportSubmitFeedback - let informativeText = UserText.dataImportFailedBody - - let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 250, height: 0)) - textView.applyLabelStyle() - - let attributedString = NSMutableAttributedString(string: informativeText) - attributedString.addLink("", toText: linkText) // The actual value of the link isn't important, we're reacting to the click via the delegate - attributedString.addAttributes([ - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - .foregroundColor: NSColor(named: "BlackWhite60")! - ], range: NSRange(location: 0, length: attributedString.length)) - - textView.textStorage?.setAttributedString(attributedString) - - textView.sizeToFit() - textView.delegate = linkDelegate - - alert.messageText = UserText.dataImportFailedTitle - alert.accessoryView = textView - alert.alertStyle = .warning - alert.addButton(withTitle: UserText.dataImportAlertAccept) - - return alert - } - static func passwordRequiredAlert(source: DataImport.Source) -> NSAlert { let textField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) let alert = NSAlert() @@ -68,15 +39,4 @@ extension NSAlert { return alert } - static func failureAlert(message: String) -> NSAlert { - let alert = NSAlert() - - alert.messageText = UserText.dataImportFailedTitle - alert.informativeText = message - alert.alertStyle = .warning - alert.addButton(withTitle: UserText.dataImportAlertAccept) - - return alert - } - } From 5b28b0e610e8092a25179c1302342ad245142e9f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 20 Nov 2023 14:18:11 +0600 Subject: [PATCH 20/83] refactor data import feedback --- .../Model/DataImportReportModel.swift | 40 +++++++++++++++ .../Model/DataImportViewModel.swift | 50 +++++++++++-------- .../DataImport/View/DataImportView.swift | 22 +------- .../DataImport/View/ReportFeedbackView.swift | 37 ++++---------- .../Feedback/Model/FeedbackSender.swift | 14 ++++++ 5 files changed, 95 insertions(+), 68 deletions(-) create mode 100644 DuckDuckGo/DataImport/Model/DataImportReportModel.swift diff --git a/DuckDuckGo/DataImport/Model/DataImportReportModel.swift b/DuckDuckGo/DataImport/Model/DataImportReportModel.swift new file mode 100644 index 0000000000..c0eaaf4aef --- /dev/null +++ b/DuckDuckGo/DataImport/Model/DataImportReportModel.swift @@ -0,0 +1,40 @@ +// +// DataImportReportModel.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import Foundation + +struct DataImportReportModel { + + var osVersion: String = "\(ProcessInfo.processInfo.operatingSystemVersion)" + var appVersion: String = "\(AppVersion.shared.versionNumber)" + + var importSource: DataImport.Source + var importSourceVersion: String? + + var importSourceDescription: String { + [importSource.importSourceName, importSourceVersion].compactMap { $0 }.joined(separator: " ") + } + + var error: LocalizedError + + var text: String = "" + + var retryNumber: Int + +} diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 42e489fbdc..0719455458 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -48,9 +48,9 @@ struct DataImportViewModel { /// Show Open Panel to choose CSV/HTML file private let openPanelCallback: @MainActor (DataType) -> URL? - typealias FeedbackSenderFactory = () -> (Feedback) -> Void + typealias ReportSenderFactory = () -> (DataImportReportModel) -> Void /// Factory for a DataImporter for importSource - private let feedbackSenderFactory: FeedbackSenderFactory + private let reportSenderFactory: ReportSenderFactory enum Screen: Hashable { case profileAndDataTypesPicker @@ -59,7 +59,7 @@ struct DataImportViewModel { case fileImport(DataType) case fileImportSummary(DataType) case summary - case feedback(String = "") + case feedback var fileImportDataType: DataType? { if case .fileImport(let dataType) = self { return dataType } @@ -82,12 +82,14 @@ struct DataImportViewModel { /// collected import summary for current import operation per selected import source private(set) var summary = DataImportViewSummary() + private var userReportText: String = "" + init(importSource: Source? = nil, loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() }, dataImporterFactory: @escaping DataImporterFactory = dataImporter, requestPrimaryPasswordCallback: @escaping @MainActor (Source) -> String? = Self.requestPrimaryPasswordCallback, openPanelCallback: @escaping @MainActor (DataType) -> URL? = Self.openPanelCallback, - feedbackSenderFactory: @escaping FeedbackSenderFactory = { FeedbackSender().sendFeedback }) { + reportSenderFactory: @escaping ReportSenderFactory = { FeedbackSender().sendDataImportReport }) { let importSource = importSource ?? ThirdPartyBrowser.installedBrowsers.first?.importSource ?? .csv @@ -104,7 +106,7 @@ struct DataImportViewModel { self.requestPrimaryPasswordCallback = requestPrimaryPasswordCallback self.openPanelCallback = openPanelCallback - self.feedbackSenderFactory = feedbackSenderFactory + self.reportSenderFactory = reportSenderFactory } /// Import button press (starts browser data import) @@ -163,6 +165,7 @@ struct DataImportViewModel { private mutating func handleErrors(_ summary: [DataType: any DataImportError]) -> Bool { for error in summary.values { switch error { + // TODO: Chrome user denied keychain prompt error // firefox passwords db is master-password protected: request password case let error as FirefoxLoginReader.ImportError where error.type == .requiresPrimaryPassword: @@ -218,7 +221,7 @@ struct DataImportViewModel { break } // all done - return areAllSelectedDataTypesSuccessfullyImported ? .summary : .feedback() + return areAllSelectedDataTypesSuccessfullyImported ? .summary : .feedback } /// Skip button press @@ -238,16 +241,8 @@ struct DataImportViewModel { } func submitReport() { - guard case .feedback(let comment) = screen else { - assertionFailure("wrong screen \(screen)") - return - } - let sendFeedback = feedbackSenderFactory() - sendFeedback(Feedback(category: .dataImport, - // TODO: import source version - comment: comment.trimmingWhitespace() + "\n\n---\n\n" + summarizedError.localizedDescription, - appVersion: "\(AppVersion.shared.versionNumber)", - osVersion: "\(ProcessInfo.processInfo.operatingSystemVersion)")) + let sendReport = reportSenderFactory() + sendReport(reportModel) } } @@ -433,9 +428,9 @@ extension DataImportViewModel { case .profileAndDataTypesPicker: guard let importer = selectedProfile.map({ dataImporterFactory(/* importSource: */ importSource, - /* dataType: */ nil, - /* profileURL: */ $0.profileURL, - /* primaryPassword: */ nil) + /* dataType: */ nil, + /* profileURL: */ $0.profileURL, + /* primaryPassword: */ nil) }), selectedDataTypes.intersects(importer.importableTypes) else { // no profiles found @@ -524,7 +519,7 @@ extension DataImportViewModel { } mutating func update(with importSource: Source) { - self = .init(importSource: importSource, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, feedbackSenderFactory: feedbackSenderFactory) + self = .init(importSource: importSource, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, reportSenderFactory: reportSenderFactory) } @MainActor @@ -573,8 +568,19 @@ extension DataImportViewModel { return newState } - mutating func updateFeedbackComment(_ comment: String) { - self.screen = .feedback(comment) + private var retryNumber: Int { + summary.reduce(into: [:]) { + // get maximum number of failures per data type + $0[$1.dataType, default: 0] += $1.result.isSuccess ? 0 : 1 + }.values.max() ?? 0 + } + + var reportModel: DataImportReportModel { + get { + DataImportReportModel(importSource: importSource, error: summarizedError, text: userReportText, retryNumber: retryNumber) + } set { + userReportText = newValue.text + } } } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index dbf20b7494..71be1a0f61 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -49,18 +49,6 @@ struct DataImportView: View { @State private var progressText: String? @State private var progressFraction: Double? - private func feedbackComment() -> Binding { - Binding { - guard case .feedback(let comment) = viewModel.screen else { - assertionFailure("wrong screen") - return "" - } - return comment - } set: { - viewModel.updateFeedbackComment($0) - } - } - var body: some View { VStack(alignment: .leading, spacing: 0) { Text("Import Browser Data") @@ -140,13 +128,7 @@ struct DataImportView: View { }) case .feedback: - ReportFeedbackView(text: feedbackComment(), - retryNumber: viewModel.summary.reduce(into: [:]) { - // get maximum number of failures per data type - $0[$1.dataType, default: 0] += $1.result.isSuccess ? 0 : 1 - }.values.max() ?? 0, - importSource: viewModel.importSource, - error: viewModel.summarizedError) + ReportFeedbackView(model: $viewModel.reportModel) } // Import in progress… @@ -401,7 +383,7 @@ extension DataImportViewModel.ButtonType { return "password" } openPanelCallback: { _ in URL(fileURLWithPath: "/test/path") - } feedbackSenderFactory: { + } reportSenderFactory: { { feedback in print("send feedback:", feedback) } diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift index af35da06d0..00ad82ddfc 100644 --- a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -16,30 +16,20 @@ // limitations under the License. // -import Common import SwiftUI struct ReportFeedbackView: View { - @Binding var text: String + @Binding var model: DataImportReportModel - let retryNumber: Int - var title: LocalizedStringKey { - if retryNumber <= 1 { + private var title: LocalizedStringKey { + if model.retryNumber <= 1 { "Please submit a report to help us fix the issue." } else { "That didn’t work either. Please submit a report to help us fix the issue." } } - // TODO: pass these from main view - var osVersion: String = "\(ProcessInfo.processInfo.operatingSystemVersion)" - var appVersion: String = "\(AppVersion.shared.versionNumber)" - var importSource: DataImport.Source - - var importSourceVersion: String? - var error: LocalizedError - var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -52,23 +42,22 @@ struct ReportFeedbackView: View { The following information will be sent to DuckDuckGo. No personally identifiable information will be sent. """) - InfoItemView("macOS version", osVersion) - InfoItemView("DuckDuckGo browser version", appVersion) - InfoItemView("The version of the browser you are trying to import from", - importSource.importSourceName + (importSourceVersion.map { " \($0)" } ?? "")) - InfoItemView("Error message & code", error.localizedDescription) + InfoItemView("macOS version", model.osVersion) + InfoItemView("DuckDuckGo browser version", model.appVersion) + InfoItemView("The version of the browser you are trying to import from", model.importSourceDescription) + InfoItemView("Error message & code", model.error.localizedDescription) } Spacer().frame(height: 24) ZStack(alignment: .top) { - EditableTextView(text: $text, + EditableTextView(text: $model.text, font: NSFont(name: "SF Pro Text", size: 13), insets: NSSize(width: 11, height: 11)) .cornerRadius(6) .frame(height: 114) .shadow(radius: 1, x: 0, y: 1) - if text.isEmpty { + if model.text.isEmpty { HStack { Text("Add any details that you think may help us fix the problem") .font(.custom("SF Pro Text", size: 13)) @@ -113,11 +102,7 @@ private struct InfoItemView: View { #Preview { - ReportFeedbackView(text: .constant(""), - retryNumber: 2, - importSource: .safari, - importSourceVersion: UserAgent.safariVersion, - error: { + ReportFeedbackView(model: .constant(.init(importSource: .safari, importSourceVersion: UserAgent.safariVersion, error: { enum ImportError: DataImportError { enum OperationType: Int { case imp @@ -138,7 +123,7 @@ private struct InfoItemView: View { case err(Error) } return ImportError.err(CocoaError(.fileReadUnknown)) - }()) + }(), retryNumber: 1))) .frame(width: 512 - 20) .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) diff --git a/DuckDuckGo/Feedback/Model/FeedbackSender.swift b/DuckDuckGo/Feedback/Model/FeedbackSender.swift index 4c097a9777..df78c53a01 100644 --- a/DuckDuckGo/Feedback/Model/FeedbackSender.swift +++ b/DuckDuckGo/Feedback/Model/FeedbackSender.swift @@ -48,6 +48,20 @@ final class FeedbackSender { } } + func sendDataImportReport(_ report: DataImportReportModel) { + sendFeedback(Feedback(category: .dataImport, + comment: """ + \(report.text.trimmingWhitespace()) + + --- + + Import source: \(report.importSourceDescription) + Error: \(report.error.localizedDescription) + """, + appVersion: "\(AppVersion.shared.versionNumber)", + osVersion: "\(ProcessInfo.processInfo.operatingSystemVersion)")) + } + } fileprivate extension Feedback.Category { From 0771496c6399997d279406bd8d0f8259bd3510c8 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 20 Nov 2023 17:05:26 +0600 Subject: [PATCH 21/83] fix typo --- DuckDuckGo/DataImport/View/DataImportProfilePicker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index e9172110f9..12a475bbc1 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -62,6 +62,6 @@ struct DataImportProfilePicker: View { .init(browser: .chrome, profileURL: URL(fileURLWithPath: "/test/Profile 1")) } set: { - print("seiection:", $0) + print("Profile selected:", $0.debugDescription ?? "") }) } From 552d31805ba080c55387632a910fb5fc1ecbaf5c Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 20 Nov 2023 17:06:54 +0600 Subject: [PATCH 22/83] fix pbxproj --- DuckDuckGo.xcodeproj/project.pbxproj | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index f398715b5e..d6759c9c4d 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -3548,6 +3548,10 @@ B6B1E88B26D774090062C350 /* LinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B1E88A26D774090062C350 /* LinkButton.swift */; }; B6B2400E28083B49001B8F3A /* WebViewContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B2400D28083B49001B8F3A /* WebViewContainerView.swift */; }; B6B3E0E12657EA7A0040E0A2 /* NSScreenExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B3E0DC2657E9CF0040E0A2 /* NSScreenExtension.swift */; }; + B6B4D1C52B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1C62B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1C72B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1C82B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; B6B5F57F2B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; B6B5F5802B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; B6B5F5812B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; @@ -4873,6 +4877,7 @@ B6B1E88A26D774090062C350 /* LinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkButton.swift; sourceTree = ""; }; B6B2400D28083B49001B8F3A /* WebViewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContainerView.swift; sourceTree = ""; }; B6B3E0DC2657E9CF0040E0A2 /* NSScreenExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSScreenExtension.swift; sourceTree = ""; }; + B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportReportModel.swift; sourceTree = ""; }; B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSummaryView.swift; sourceTree = ""; }; B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestFilePermissionView.swift; sourceTree = ""; }; B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMoreInfoView.swift; sourceTree = ""; }; @@ -8489,6 +8494,7 @@ children = ( B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */, B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */, + B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */, ); path = Model; sourceTree = ""; @@ -10237,6 +10243,7 @@ 3192A0442A4C4CFF0084EA89 /* DownloadsPreferences.swift in Sources */, 4B67854C2AA8DE78008A5004 /* NetworkProtectionFeatureVisibility.swift in Sources */, 3192A0452A4C4CFF0084EA89 /* PasswordManagementItemList.swift in Sources */, + B6B4D1C72B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 3192A0462A4C4CFF0084EA89 /* Bookmark.swift in Sources */, 3192A0472A4C4CFF0084EA89 /* ConnectBitwardenViewModel.swift in Sources */, 3192A0492A4C4CFF0084EA89 /* NSNotificationName+DataImport.swift in Sources */, @@ -10819,6 +10826,7 @@ 3706FABD293F65D500E42796 /* NSNotificationName+PasswordManager.swift in Sources */, 3706FABE293F65D500E42796 /* RulesCompilationMonitor.swift in Sources */, 3706FABF293F65D500E42796 /* CrashReportReader.swift in Sources */, + B6B4D1C62B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 3706FAC0293F65D500E42796 /* DataTaskProviding.swift in Sources */, 3706FAC1293F65D500E42796 /* FeedbackViewController.swift in Sources */, 3706FAC2293F65D500E42796 /* FaviconSelector.swift in Sources */, @@ -11917,6 +11925,7 @@ 4B9579942AC7AE700062CA31 /* CrashReportReader.swift in Sources */, 4B9579952AC7AE700062CA31 /* DataTaskProviding.swift in Sources */, 4B9579962AC7AE700062CA31 /* FeatureFlag.swift in Sources */, + B6B4D1C82B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 4B9579972AC7AE700062CA31 /* FeedbackViewController.swift in Sources */, 4B9579982AC7AE700062CA31 /* FaviconSelector.swift in Sources */, 4B9579992AC7AE700062CA31 /* AddEditFavoriteViewController.swift in Sources */, @@ -12624,6 +12633,7 @@ 857FFEC027D239DC00415E7A /* HyperLink.swift in Sources */, 37445F992A1566420029F789 /* SyncDataProviders.swift in Sources */, 4B9292A426670D2A00AD2C21 /* PasteboardWriting.swift in Sources */, + B6B4D1C52B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 4B92928D26670D1700AD2C21 /* BookmarkOutlineViewCell.swift in Sources */, B604085C274B8FBA00680351 /* UnprotectedDomains.xcdatamodeld in Sources */, 4BB88B5025B7BA2B006F6B06 /* TabInstrumentation.swift in Sources */, From 8848513bb34547032606e2119a6037eefb1c3d8f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 20 Nov 2023 17:39:23 +0600 Subject: [PATCH 23/83] fix typo --- DuckDuckGo/DataImport/View/DataImportProfilePicker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index 12a475bbc1..bbaec6adab 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -62,6 +62,6 @@ struct DataImportProfilePicker: View { .init(browser: .chrome, profileURL: URL(fileURLWithPath: "/test/Profile 1")) } set: { - print("Profile selected:", $0.debugDescription ?? "") + print("Profile selected:", $0?.profileURL.lastPathComponent ?? "") }) } From 76025c20ffa80a5c8561fc7f2c0b76b97ad1c844 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 21 Nov 2023 14:01:21 +0600 Subject: [PATCH 24/83] remove source arg from Importers --- .../Chromium/ChromiumBookmarksReader.swift | 10 ++----- .../Chromium/ChromiumFaviconsReader.swift | 7 ++--- .../Firefox/FirefoxBookmarksReader.swift | 9 +++---- .../Firefox/FirefoxFaviconsReader.swift | 7 ++--- .../Bookmarks/HTML/BookmarkHTMLReader.swift | 1 - .../Safari/SafariBookmarksReader.swift | 1 - .../Bookmarks/Safari/SafariDataImporter.swift | 17 +++++++----- .../Safari/SafariFaviconsReader.swift | 1 - DuckDuckGo/DataImport/DataImport.swift | 25 ++++++++++++++--- .../DataImport/Logins/CSV/CSVImporter.swift | 5 ++-- .../Chromium/ChromiumDataImporter.swift | 26 +++++++++--------- .../Logins/Chromium/ChromiumLoginReader.swift | 27 +++++++++---------- .../Logins/Chromium/YandexDataImporter.swift | 5 ++-- .../Logins/Firefox/FirefoxDataImporter.swift | 24 +++++++++-------- .../Firefox/FirefoxEncryptionKeyReader.swift | 13 ++++----- .../Logins/Firefox/FirefoxLoginReader.swift | 16 +++++------ .../Model/DataImportViewModel.swift | 22 ++++++++------- .../DataImport/View/DataImportView.swift | 3 +-- .../DataImport/View/ReportFeedbackView.swift | 1 - DuckDuckGo/Statistics/PixelEvent.swift | 10 +++---- DuckDuckGo/Statistics/PixelParameters.swift | 2 +- 21 files changed, 113 insertions(+), 119 deletions(-) diff --git a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift index 3c1179f1a6..ebb946a18d 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift @@ -31,20 +31,14 @@ final class ChromiumBookmarksReader { } var action: DataImportAction { .bookmarks } - let source: DataImport.Source let type: OperationType let underlyingError: Error? } - func importError(type: ImportError.OperationType, underlyingError: Error) -> ImportError { - ImportError(source: source, type: type, underlyingError: underlyingError) - } private let chromiumBookmarksFileURL: URL - private let source: DataImport.Source - init(chromiumDataDirectoryURL: URL, source: DataImport.Source, bookmarksFileName: String = Constants.defaultBookmarksFileName) { + init(chromiumDataDirectoryURL: URL, bookmarksFileName: String = Constants.defaultBookmarksFileName) { self.chromiumBookmarksFileURL = chromiumDataDirectoryURL.appendingPathComponent(bookmarksFileName) - self.source = source } func readBookmarks() -> DataImportResult { @@ -55,7 +49,7 @@ final class ChromiumBookmarksReader { let decodedBookmarks = try JSONDecoder().decode(ImportedBookmarks.self, from: bookmarksFileData) return .success(decodedBookmarks) } catch { - return .failure(importError(type: currentOperationType, underlyingError: error)) + return .failure(ImportError(type: currentOperationType, underlyingError: error)) } } diff --git a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift index c1d485bcdf..3fae74e514 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift @@ -33,12 +33,11 @@ final class ChromiumFaviconsReader { } var action: DataImportAction { .favicons } - let source: DataImport.Source let type: OperationType let underlyingError: Error? } func importError(type: ImportError.OperationType, underlyingError: Error) -> ImportError { - ImportError(source: source, type: type, underlyingError: underlyingError) + ImportError(type: type, underlyingError: underlyingError) } final class ChromiumFavicon: FetchableRecord { @@ -61,11 +60,9 @@ final class ChromiumFaviconsReader { private let chromiumFaviconsDatabaseURL: URL private var currentOperationType: ImportError.OperationType = .copyTemporaryFile - private let source: DataImport.Source - init(chromiumDataDirectoryURL: URL, source: DataImport.Source) { + init(chromiumDataDirectoryURL: URL) { self.chromiumFaviconsDatabaseURL = chromiumDataDirectoryURL.appendingPathComponent(Constants.faviconsDatabaseName) - self.source = source } func readFavicons() -> DataImportResult<[String: [ChromiumFavicon]]> { diff --git a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift index 6f0024f2af..9651196020 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift @@ -47,17 +47,14 @@ final class FirefoxBookmarksReader { } var action: DataImportAction { .bookmarks } - let source: DataImport.Source let type: OperationType let underlyingError: Error? } private let firefoxPlacesDatabaseURL: URL - private let source: DataImport.Source private var currentOperationType: ImportError.OperationType = .copyTemporaryFile - init(source: DataImport.Source, firefoxDataDirectoryURL: URL) { - self.source = source + init(firefoxDataDirectoryURL: URL) { self.firefoxPlacesDatabaseURL = firefoxDataDirectoryURL.appendingPathComponent(Constants.placesDatabaseName) } @@ -71,7 +68,7 @@ final class FirefoxBookmarksReader { } catch let error as ImportError { return .failure(error) } catch { - return .failure(ImportError(source: source, type: currentOperationType, underlyingError: error)) + return .failure(ImportError(type: currentOperationType, underlyingError: error)) } } @@ -84,7 +81,7 @@ final class FirefoxBookmarksReader { let bookmarks: DatabaseBookmarks = try queue.read { database in currentOperationType = .fetchRootEntries let rootEntries = try FolderRow.fetchAll(database, sql: rootEntryQuery()) - guard let rootEntry = rootEntries.first else { throw ImportError(source: source, type: .noRootEntries, underlyingError: nil) } + guard let rootEntry = rootEntries.first else { throw ImportError(type: .noRootEntries, underlyingError: nil) } assert(rootEntries.count == 1, "moz_bookmarks should only have one root entry") diff --git a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift index 3e77b9f2a7..7b15a19091 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift @@ -33,7 +33,6 @@ final class FirefoxFaviconsReader { } var action: DataImportAction { .favicons } - let source: DataImport.Source let type: OperationType let underlyingError: Error? } @@ -56,12 +55,10 @@ final class FirefoxFaviconsReader { } } - private let source: DataImport.Source private let firefoxFaviconsDatabaseURL: URL private var currentOperationType: ImportError.OperationType = .copyTemporaryFile - init(source: DataImport.Source, firefoxDataDirectoryURL: URL) { - self.source = source + init(firefoxDataDirectoryURL: URL) { self.firefoxFaviconsDatabaseURL = firefoxDataDirectoryURL.appendingPathComponent(Constants.faviconsDatabaseName) } @@ -75,7 +72,7 @@ final class FirefoxFaviconsReader { } catch let error as ImportError { return .failure(error) } catch { - return .failure(ImportError(source: source, type: currentOperationType, underlyingError: error)) + return .failure(ImportError(type: currentOperationType, underlyingError: error)) } } diff --git a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift index fdcfe5290f..920835f1c0 100644 --- a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift @@ -38,7 +38,6 @@ final class BookmarkHTMLReader { } var action: DataImportAction { .bookmarks } - var source: DataImport.Source { .bookmarksHTML } let type: OperationType let underlyingError: Error? } diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift index 53d54a312e..dbd74aa9fd 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift @@ -44,7 +44,6 @@ final class SafariBookmarksReader { } var action: DataImportAction { .bookmarks } - var source: DataImport.Source { .safari } let type: OperationType let underlyingError: Error? } diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift index 095c143c36..2f4b64427e 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift @@ -33,12 +33,15 @@ final class SafariDataImporter: DataImporter { return openPanel.urls.first } - private let safariDataDirectoryUrl: URL private let bookmarkImporter: BookmarkImporter private let faviconManager: FaviconManagement + private let profile: DataImport.BrowserProfile + private var source: DataImport.Source { + profile.browser.importSource + } - init(source: DataImport.Source, profileURL: URL, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement = FaviconManager.shared) { - self.safariDataDirectoryUrl = profileURL + init(profile: DataImport.BrowserProfile, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement = FaviconManager.shared) { + self.profile = profile self.bookmarkImporter = bookmarkImporter self.faviconManager = faviconManager } @@ -57,7 +60,7 @@ final class SafariDataImporter: DataImporter { static private let bookmarksFileName = "Bookmarks.plist" private var fileUrl: URL { - safariDataDirectoryUrl.appendingPathComponent(Self.bookmarksFileName) + profile.profileURL.appendingPathComponent(Self.bookmarksFileName) } func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { @@ -78,11 +81,11 @@ final class SafariDataImporter: DataImporter { let bookmarkResult = bookmarkReader.readBookmarks() let summary = bookmarkResult.map { bookmarks in - bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(.safari)) + bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(source)) } if case .success = summary { - await importFavicons(from: safariDataDirectoryUrl) + await importFavicons(from: profile.profileURL) } return [.bookmarks: summary.map { .init($0) }] @@ -109,7 +112,7 @@ final class SafariDataImporter: DataImporter { await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(error)) + Pixel.fire(.dataImportFailed(source: source, error: error)) } } diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift index f1d39d117d..ed84ffb037 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift @@ -35,7 +35,6 @@ final class SafariFaviconsReader { } var action: DataImportAction { .favicons } - var source: DataImport.Source { .safari } let type: OperationType let underlyingError: Error? } diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 6dc67e7b9d..4a0cee045c 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -339,7 +339,6 @@ enum DataImportAction { protocol DataImportError: Error, CustomNSError, ErrorWithPixelParameters, LocalizedError { associatedtype OperationType: RawRepresentable where OperationType.RawValue == Int - var source: DataImport.Source { get } var action: DataImportAction { get } var type: OperationType { get } var underlyingError: Error? { get } @@ -480,16 +479,34 @@ enum DataImportResult { } +extension DataImportResult: Equatable where T: Equatable { + static func == (lhs: DataImportResult, rhs: DataImportResult) -> Bool { + switch lhs { + case .success(let value): + if case .success(value) = rhs { + true + } else { + false + } + case .failure(let error1): + if case .failure(let error2) = rhs { + error1.errorParameters == error2.errorParameters + } else { + false + } + } + } + +} + struct LoginImporterError: DataImportError { private let error: Error? private let _type: OperationType? var action: DataImportAction { .logins } - let source: DataImport.Source - init(source: DataImport.Source, error: Error?, type: OperationType? = nil) { - self.source = source + init(error: Error?, type: OperationType? = nil) { self.error = error self._type = type } diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index 62ea8697bc..3857011b72 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -144,7 +144,6 @@ final class CSVImporter: DataImporter { } var action: DataImportAction { .logins } - var source: DataImport.Source { .csv } let type: OperationType let underlyingError: Error? } @@ -216,7 +215,7 @@ final class CSVImporter: DataImporter { do { let loginCredentials = try Self.extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? { - throw LoginImporterError(source: .csv, error: nil, type: .malformedCSV) + throw LoginImporterError(error: nil, type: .malformedCSV) }() let summary = try loginImporter.importLogins(loginCredentials) @@ -225,7 +224,7 @@ final class CSVImporter: DataImporter { } catch let error as DataImportError { return .failure(error) } catch { - return .failure(LoginImporterError(source: .csv, error: error)) + return .failure(LoginImporterError(error: error)) } } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift index 1cd8d404db..d3e28bb973 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift @@ -23,20 +23,20 @@ internal class ChromiumDataImporter: DataImporter { private let bookmarkImporter: BookmarkImporter private let loginImporter: LoginImporter? private let faviconManager: FaviconManagement - private let profileURL: URL - private let source: DataImport.Source + private let profile: DataImport.BrowserProfile + private var source: DataImport.Source { + profile.browser.importSource + } - init(source: DataImport.Source, profileURL: URL, loginImporter: LoginImporter?, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { - self.source = source - self.profileURL = profileURL + init(profile: DataImport.BrowserProfile, loginImporter: LoginImporter?, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { + self.profile = profile self.loginImporter = loginImporter self.bookmarkImporter = bookmarkImporter self.faviconManager = faviconManager } - convenience init(source: DataImport.Source, profileURL: URL, loginImporter: LoginImporter?, bookmarkImporter: BookmarkImporter) { - self.init(source: source, - profileURL: profileURL, + convenience init(profile: DataImport.BrowserProfile, loginImporter: LoginImporter?, bookmarkImporter: BookmarkImporter) { + self.init(profile: profile, loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: FaviconManager.shared) @@ -57,14 +57,14 @@ internal class ChromiumDataImporter: DataImporter { var summary = DataImportSummary() if types.contains(.passwords), let loginImporter { - let loginReader = ChromiumLoginReader(chromiumDataDirectoryURL: profileURL, source: source) + let loginReader = ChromiumLoginReader(chromiumDataDirectoryURL: profile.profileURL, source: source) let loginResult = loginReader.readLogins(modalWindow: nil) let loginsSummary = loginResult.flatMap { logins in do { return try .success(loginImporter.importLogins(logins)) } catch { - return .failure(LoginImporterError(source: source, error: error)) + return .failure(LoginImporterError(error: error)) } } @@ -72,7 +72,7 @@ internal class ChromiumDataImporter: DataImporter { } if types.contains(.bookmarks) { - let bookmarkReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: profileURL, source: source) + let bookmarkReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: profile.profileURL) let bookmarkResult = bookmarkReader.readBookmarks() let bookmarksSummary = bookmarkResult.map { bookmarks in @@ -90,7 +90,7 @@ internal class ChromiumDataImporter: DataImporter { } private func importFavicons() async { - let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: profileURL, source: source) + let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: profile.profileURL) let faviconsResult = faviconsReader.readFavicons() switch faviconsResult { @@ -110,7 +110,7 @@ internal class ChromiumDataImporter: DataImporter { await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(error)) + Pixel.fire(.dataImportFailed(source: source, error: error)) } } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift index 673b0a21ce..070b169731 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift @@ -37,16 +37,13 @@ final class ChromiumLoginReader { } var action: DataImportAction { .logins } - let source: DataImport.Source let type: OperationType - let underlyingError: Error? + var underlyingError: Error? } - private func importError(type: ImportError.OperationType, underlyingError: Error? = nil) -> ImportError { - ImportError(source: source, type: type, underlyingError: underlyingError) - } + private func decryptionKeyAccessFailed(_ status: OSStatus) -> ImportError { - importError(type: .decryptionKeyAccessFailed, underlyingError: NSError(domain: "KeychainError", code: Int(status))) + ImportError(type: .decryptionKeyAccessFailed, underlyingError: NSError(domain: "KeychainError", code: Int(status))) } enum LoginDataFileName: String, CaseIterable { @@ -103,8 +100,8 @@ final class ChromiumLoginReader { switch keyPromptResult { case .password(let passwordString): key = passwordString - case .failedToDecodePasswordData: return .failure(importError(type: .failedToDecodePasswordData)) - case .none, .userDeniedKeychainPrompt: return .failure(importError(type: .userDeniedKeychainPrompt)) + case .failedToDecodePasswordData: return .failure(ImportError(type: .failedToDecodePasswordData)) + case .none, .userDeniedKeychainPrompt: return .failure(ImportError(type: .userDeniedKeychainPrompt)) case .keychainError(let status): return .failure(decryptionKeyAccessFailed(status)) } } @@ -113,7 +110,7 @@ final class ChromiumLoginReader { do { derivedKey = try deriveKey(from: key) } catch { - return .failure(importError(type: .decryptionFailed, underlyingError: error)) + return .failure(ImportError(type: .decryptionFailed, underlyingError: error)) } return readLogins(using: derivedKey) @@ -124,7 +121,7 @@ final class ChromiumLoginReader { .filter { FileManager.default.fileExists(atPath: $0.path) } guard !loginFileURLs.isEmpty else { - return .failure(importError(type: .couldNotFindLoginData)) + return .failure(ImportError(type: .couldNotFindLoginData)) } var loginRows = [ChromiumCredential.ID: ChromiumCredential]() @@ -153,7 +150,7 @@ final class ChromiumLoginReader { } catch let error as ImportError { return .failure(error) } catch { - return .failure(importError(type: .createImportedLoginCredentialsFailure, underlyingError: error)) + return .failure(ImportError(type: .createImportedLoginCredentialsFailure, underlyingError: error)) } } @@ -165,7 +162,7 @@ final class ChromiumLoginReader { do { temporaryDatabaseURL = try temporaryFileHandler.copyFileToTemporaryDirectory() } catch { - return .failure(importError(type: .failedToTemporarilyCopyDatabase, underlyingError: error)) + return .failure(ImportError(type: .failedToTemporarilyCopyDatabase, underlyingError: error)) } var loginRows = [ChromiumCredential.ID: ChromiumCredential]() @@ -193,7 +190,7 @@ final class ChromiumLoginReader { } } catch { - return .failure(importError(type: .databaseAccessFailed, underlyingError: error)) + return .failure(ImportError(type: .databaseAccessFailed, underlyingError: error)) } return .success(loginRows) @@ -248,13 +245,13 @@ final class ChromiumLoginReader { } private func decrypt(passwordData: Data, with key: Data) throws -> String { - guard passwordData.count >= 4 else { throw importError(type: .passwordDataTooShort, underlyingError: nil) } + guard passwordData.count >= 4 else { throw ImportError(type: .passwordDataTooShort, underlyingError: nil) } let trimmedPasswordData = passwordData[3...] let iv = String(repeating: " ", count: 16).utf8data let decrypted = try Cryptography.decryptAESCBC(data: trimmedPasswordData, key: key, iv: iv) - return try String(data: decrypted, encoding: .utf8) ?? { throw importError(type: .dataToStringConversionError, underlyingError: nil) }() + return try String(data: decrypted, encoding: .utf8) ?? { throw ImportError(type: .dataToStringConversionError, underlyingError: nil) }() } } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift index 94a2809049..c19ac930ac 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift @@ -20,9 +20,8 @@ import Foundation final class YandexDataImporter: ChromiumDataImporter { - init(profileURL: URL, bookmarkImporter: BookmarkImporter) { - super.init(source: .yandex, - profileURL: profileURL, + init(profile: DataImport.BrowserProfile, bookmarkImporter: BookmarkImporter) { + super.init(profile: profile, loginImporter: nil, bookmarkImporter: bookmarkImporter, faviconManager: FaviconManager.shared) diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift index 5b81a2a31d..66a05a4e20 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift @@ -24,13 +24,15 @@ internal class FirefoxDataImporter: DataImporter { private let loginImporter: LoginImporter private let bookmarkImporter: BookmarkImporter private let faviconManager: FaviconManagement - private let profileURL: URL - private let source: DataImport.Source + private let profile: DataImport.BrowserProfile + private var source: DataImport.Source { + profile.browser.importSource + } + private let primaryPassword: String? - init(source: DataImport.Source, profileURL: URL, primaryPassword: String?, loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { - self.source = source - self.profileURL = profileURL + init(profile: DataImport.BrowserProfile, primaryPassword: String?, loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { + self.profile = profile self.primaryPassword = primaryPassword self.loginImporter = loginImporter self.bookmarkImporter = bookmarkImporter @@ -54,14 +56,14 @@ internal class FirefoxDataImporter: DataImporter { if types.contains(.passwords) { try? updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) - let loginReader = FirefoxLoginReader(source: source, firefoxProfileURL: profileURL, primaryPassword: self.primaryPassword) + let loginReader = FirefoxLoginReader(firefoxProfileURL: profile.profileURL, primaryPassword: self.primaryPassword) let loginResult = loginReader.readLogins(dataFormat: nil) let loginsSummary = loginResult.flatMap { logins in do { return try .success(loginImporter.importLogins(logins)) } catch { - return .failure(LoginImporterError(source: source, error: error)) + return .failure(LoginImporterError(error: error)) } } @@ -73,7 +75,7 @@ internal class FirefoxDataImporter: DataImporter { if types.contains(.bookmarks) { try? updateProgress(.importingBookmarks(numberOfBookmarks: nil, fraction: 0.0)) - let bookmarkReader = FirefoxBookmarksReader(source: source, firefoxDataDirectoryURL: profileURL) + let bookmarkReader = FirefoxBookmarksReader(firefoxDataDirectoryURL: profile.profileURL) let bookmarkResult = bookmarkReader.readBookmarks() let bookmarksSummary = bookmarkResult.map { bookmarks in @@ -94,7 +96,7 @@ internal class FirefoxDataImporter: DataImporter { } private func importFavicons() async { - let faviconsReader = FirefoxFaviconsReader(source: source, firefoxDataDirectoryURL: profileURL) + let faviconsReader = FirefoxFaviconsReader(firefoxDataDirectoryURL: profile.profileURL) let faviconsResult = faviconsReader.readFavicons() switch faviconsResult { @@ -114,7 +116,7 @@ internal class FirefoxDataImporter: DataImporter { await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(error)) + Pixel.fire(.dataImportFailed(source: source, error: error)) } } @@ -122,7 +124,7 @@ internal class FirefoxDataImporter: DataImporter { func validateAccess(for selectedDataTypes: Set) -> [DataImport.DataType: any DataImportError]? { guard selectedDataTypes.contains(.passwords) else { return nil } - let loginReader = FirefoxLoginReader(source: source, firefoxProfileURL: profileURL, primaryPassword: nil) + let loginReader = FirefoxLoginReader(firefoxProfileURL: profile.profileURL, primaryPassword: nil) do { _=try loginReader.getEncryptionKey() return nil diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift index 3f76f14439..4a7cc35d3c 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift @@ -32,10 +32,7 @@ final class FirefoxEncryptionKeyReader: FirefoxEncryptionKeyReading { typealias KeyReaderFileLineError = FileLineError - private let source: DataImport.Source - - init(source: DataImport.Source) { - self.source = source + init() { } func getEncryptionKey(key3DatabaseURL: URL, primaryPassword: String) -> DataImportResult { @@ -46,7 +43,7 @@ final class FirefoxEncryptionKeyReader: FirefoxEncryptionKeyReading { } catch let error as FirefoxLoginReader.ImportError { return .failure(error) } catch { - return .failure(FirefoxLoginReader.ImportError(source: source, type: operationType, underlyingError: error)) + return .failure(FirefoxLoginReader.ImportError(type: operationType, underlyingError: error)) } } @@ -92,7 +89,7 @@ final class FirefoxEncryptionKeyReader: FirefoxEncryptionKeyReading { } catch let error as FirefoxLoginReader.ImportError { return .failure(error) } catch { - return .failure(FirefoxLoginReader.ImportError(source: source, type: operationType, underlyingError: error)) + return .failure(FirefoxLoginReader.ImportError(type: operationType, underlyingError: error)) } } @@ -332,7 +329,7 @@ final class FirefoxEncryptionKeyReader: FirefoxEncryptionKeyReading { let passwordCheckString = String(data: decryptedCiphertext, encoding: .utf8) - guard passwordCheckString == "password-check" else { throw FirefoxLoginReader.ImportError(source: source, type: .requiresPrimaryPassword, underlyingError: nil) } + guard passwordCheckString == "password-check" else { throw FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil) } guard let nssPrivateRow = try NssPrivateRow.fetchOne(database, sql: "SELECT a11, a102 FROM nssPrivate;") else { throw KeyReaderFileLineError() } @@ -353,7 +350,7 @@ final class FirefoxEncryptionKeyReader: FirefoxEncryptionKeyReading { let passwordCheckString = String(data: decryptedItem2, encoding: .utf8) // The password check is technically "password-check\x02\x02", it's converted to UTF-8 and checked here for simplicity - guard passwordCheckString == "password-check" else { throw FirefoxLoginReader.ImportError(source: source, type: .requiresPrimaryPassword, underlyingError: nil) } + guard passwordCheckString == "password-check" else { throw FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil) } guard let nssPrivateRow = try NssPrivateRow.fetchOne(database, sql: "SELECT a11, a102 FROM nssPrivate;") else { throw KeyReaderFileLineError() } diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift index 4ca80dbc9d..0e386591a3 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift @@ -44,7 +44,6 @@ final class FirefoxLoginReader { } var action: DataImportAction { .logins } - let source: DataImport.Source let type: OperationType let underlyingError: Error? } @@ -68,20 +67,17 @@ final class FirefoxLoginReader { private let keyReader: FirefoxEncryptionKeyReading private let primaryPassword: String? - private let source: DataImport.Source private let firefoxProfileURL: URL /// Initialize a FirefoxLoginReader with a profile path and optional primary password. /// /// - Parameter firefoxProfileURL: The path to the profile being imported from. This should be the base path of the profile, containing the database and JSON files. /// - Parameter primaryPassword: The password used to decrypt the login data. This is optional, as Firefox's primary password feature is optional. - init(source: DataImport.Source, - firefoxProfileURL: URL, + init(firefoxProfileURL: URL, keyReader: FirefoxEncryptionKeyReading? = nil, primaryPassword: String? = nil) { - self.source = source - self.keyReader = keyReader ?? FirefoxEncryptionKeyReader(source: source) + self.keyReader = keyReader ?? FirefoxEncryptionKeyReader() self.primaryPassword = primaryPassword self.firefoxProfileURL = firefoxProfileURL } @@ -89,19 +85,19 @@ final class FirefoxLoginReader { func readLogins(dataFormat: DataFormat?) -> DataImportResult<[ImportedLoginCredential]> { var currentOperationType: ImportError.OperationType = .couldNotFindLoginsFile do { - let dataFormat = try dataFormat ?? detectLoginFormat() ?? { throw ImportError(source: source, type: .couldNotDetermineFormat, underlyingError: nil) }() + let dataFormat = try dataFormat ?? detectLoginFormat() ?? { throw ImportError(type: .couldNotDetermineFormat, underlyingError: nil) }() let keyData = try getEncryptionKey(dataFormat: dataFormat) let result = try reallyReadLogins(dataFormat: dataFormat, keyData: keyData, currentOperationType: ¤tOperationType) return .success(result) } catch let error as ImportError { return .failure(error) } catch { - return .failure(ImportError(source: source, type: currentOperationType, underlyingError: error)) + return .failure(ImportError(type: currentOperationType, underlyingError: error)) } } func getEncryptionKey() throws -> Data { - let dataFormat = try detectLoginFormat() ?? { throw ImportError(source: source, type: .couldNotDetermineFormat, underlyingError: nil) }() + let dataFormat = try detectLoginFormat() ?? { throw ImportError(type: .couldNotDetermineFormat, underlyingError: nil) }() return try getEncryptionKey(dataFormat: dataFormat) } @@ -134,7 +130,7 @@ final class FirefoxLoginReader { if FileManager.default.fileExists(atPath: databaseURL.path) { guard FileManager.default.fileExists(atPath: loginsURL.path) else { - throw ImportError(source: source, type: .couldNotFindLoginsFile, underlyingError: nil) + throw ImportError(type: .couldNotFindLoginsFile, underlyingError: nil) } return potentialFormat } diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 0719455458..81eb0e550a 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -147,7 +147,7 @@ struct DataImportViewModel { self.summary.append( (dataType, result) ) if case .failure(let error) = result { - Pixel.fire(.dataImportFailed(error)) + Pixel.fire(.dataImportFailed(source: importSource, error: error)) } } @@ -250,7 +250,14 @@ struct DataImportViewModel { @MainActor private func dataImporter(for source: DataImport.Source, fileDataType: DataImport.DataType?, url: URL, primaryPassword: String?) -> DataImporter { - switch source { + var profile: DataImport.BrowserProfile { + let browser = ThirdPartyBrowser.browser(for: source) ?? { + assertionFailure("Trying to get browser name for file import source \(source)") + return .chrome + }() + return DataImport.BrowserProfile(browser: browser, profileURL: url) + } + return switch source { case .bookmarksHTML, _ where fileDataType == .bookmarks: @@ -261,23 +268,20 @@ private func dataImporter(for source: DataImport.Source, fileDataType: DataImpor CSVImporter(fileURL: url, loginImporter: SecureVaultLoginImporter(), defaultColumnPositions: .init(source: source)) case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi: - ChromiumDataImporter(source: source, - profileURL: url, + ChromiumDataImporter(profile: profile, loginImporter: SecureVaultLoginImporter(), bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) case .yandex: - YandexDataImporter(profileURL: url, + YandexDataImporter(profile: profile, bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) case .firefox, .tor: - FirefoxDataImporter(source: source, - profileURL: url, + FirefoxDataImporter(profile: profile, primaryPassword: primaryPassword, loginImporter: SecureVaultLoginImporter(), bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared), faviconManager: FaviconManager.shared) case .safari, .safariTechnologyPreview: - SafariDataImporter(source: source, - profileURL: url, + SafariDataImporter(profile: profile, bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) } } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 71be1a0f61..2ecd349148 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -254,7 +254,6 @@ extension DataImportViewModel.ButtonType { } var type: OperationType { .imp } - var source: DataImport.Source { .chrome } var action: DataImportAction { .generic } var underlyingError: Error? { if case .err(let err) = self { @@ -272,7 +271,7 @@ extension DataImportViewModel.ButtonType { } func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { - source == .firefox && types.contains(.passwords) ? [.passwords: FirefoxLoginReader.ImportError(source: .firefox, type: .requiresPrimaryPassword, underlyingError: nil)] : nil + source == .firefox && types.contains(.passwords) ? [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] : nil } func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift index 00ad82ddfc..4c33e68c36 100644 --- a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -109,7 +109,6 @@ private struct InfoItemView: View { } var type: OperationType { .imp } - var source: DataImport.Source { .chrome } var action: DataImportAction { .generic } var underlyingError: Error? { if case .err(let err) = self { diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index e3fee57617..c89a792dfd 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -100,7 +100,7 @@ extension Pixel { case dailyOsVersionCounter - case dataImportFailed(any DataImportError) + case dataImportFailed(source: DataImport.Source, error: any DataImportError) case formAutofilled(kind: FormAutofillKind) case autofillItemSaved(kind: FormAutofillKind) @@ -360,10 +360,10 @@ extension Pixel.Event { case .dailyOsVersionCounter: return "m_mac_daily-os-version-counter" - case .dataImportFailed(let error) where error.action == .favicons: - return "m_mac_favicon-import-failed_\(error.source)" - case .dataImportFailed(let error): - return "m_mac_data-import-failed_\(error.action)_\(error.source)" + case .dataImportFailed(source: let source, error: let error) where error.action == .favicons: + return "m_mac_favicon-import-failed_\(source)" + case .dataImportFailed(source: let source, error: let error): + return "m_mac_data-import-failed_\(error.action)_\(source)" case .formAutofilled(kind: let kind): return "m_mac_autofill_\(kind)" diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index ccd8e1728c..e6ff0efa12 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -37,7 +37,7 @@ extension Pixel.Event { return params - case .dataImportFailed(let error): + case .dataImportFailed(source: _, error: let error): return error.pixelParameters case .launchInitial(let cohort): From 132f3fda7a27a28ba794679fc8ba9a8ef7536fda Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 21 Nov 2023 14:06:30 +0600 Subject: [PATCH 25/83] fix tests --- .../Model/DataImportViewModel.swift | 4 +- .../Services/LocalBookmarkStoreTests.swift | 10 ++-- .../BookmarksHTMLImporterTests.swift | 47 ++++------------- .../DataImport/BrowserProfileTests.swift | 2 +- UnitTests/DataImport/CSVImporterTests.swift | 52 +++++-------------- .../ChromiumBookmarksReaderTests.swift | 2 +- .../ChromiumFaviconsReaderTests.swift | 2 +- .../DataImport/ChromiumLoginReaderTests.swift | 4 -- UnitTests/DataImport/DataImportMocks.swift | 8 +-- .../DataImport/FirefoxDataImporterTests.swift | 16 +++--- .../DataImport/FirefoxLoginReaderTests.swift | 2 +- .../DataImport/ThirdPartyBrowserTests.swift | 10 +--- .../HomePage/DataImportProviderTests.swift | 3 +- UnitTests/YoutubePlayer/DuckPlayerTests.swift | 4 +- 14 files changed, 50 insertions(+), 116 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 81eb0e550a..319b7d825e 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -259,12 +259,12 @@ private func dataImporter(for source: DataImport.Source, fileDataType: DataImpor } return switch source { case .bookmarksHTML, - _ where fileDataType == .bookmarks: + /* any */_ where fileDataType == .bookmarks: BookmarkHTMLImporter(fileURL: url, bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv, - _ where fileDataType == .passwords: + /* any */_ where fileDataType == .passwords: CSVImporter(fileURL: url, loginImporter: SecureVaultLoginImporter(), defaultColumnPositions: .init(source: source)) case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi: diff --git a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift index eba1d7665d..d5a7dbbe23 100644 --- a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift +++ b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift @@ -770,7 +770,7 @@ final class LocalBookmarkStoreTests: XCTestCase { bookmarkMO.addToFavorites(with: .displayNative(.mobile), in: context) try! context.save() - var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + let bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark bookmark.isFavorite = true bookmarkStore.update(bookmark: bookmark) @@ -792,7 +792,7 @@ final class LocalBookmarkStoreTests: XCTestCase { bookmarkMO.addToFavorites(folders: [nonNativeFolder]) try! context.save() - var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + let bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark bookmark.isFavorite = true bookmarkStore.update(bookmark: bookmark) @@ -813,7 +813,7 @@ final class LocalBookmarkStoreTests: XCTestCase { bookmarkMO.addToFavorites(with: bookmarkStore.favoritesDisplayMode, in: context) try! context.save() - var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + let bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark bookmark.isFavorite = false bookmarkStore.update(bookmark: bookmark) @@ -894,7 +894,7 @@ final class LocalBookmarkStoreTests: XCTestCase { bookmarkMO.addToFavorites(folders: [nonNativeFolder]) try! context.save() - var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + let bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark bookmark.isFavorite = true bookmarkStore.update(bookmark: bookmark) @@ -917,7 +917,7 @@ final class LocalBookmarkStoreTests: XCTestCase { bookmarkMO.addToFavorites(folders: [nonNativeFolder]) try! context.save() - var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + let bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark bookmark.isFavorite = false bookmarkStore.update(bookmark: bookmark) diff --git a/UnitTests/DataImport/BookmarksHTMLImporterTests.swift b/UnitTests/DataImport/BookmarksHTMLImporterTests.swift index dbb3f22d04..9f81a763d6 100644 --- a/UnitTests/DataImport/BookmarksHTMLImporterTests.swift +++ b/UnitTests/DataImport/BookmarksHTMLImporterTests.swift @@ -26,7 +26,7 @@ final class BookmarksHTMLImporterTests: XCTestCase { override func setUpWithError() throws { underlyingBookmarkImporter = MockBookmarkImporter(importBookmarks: { _, _ in - .init(successful: 0, duplicates: 0, failed: 0) + .init(successful: 0, duplicates: 0, failed: 0) }) } @@ -47,53 +47,28 @@ final class BookmarksHTMLImporterTests: XCTestCase { XCTAssertEqual(dataImporter.totalBookmarks, 0) } - func testWhenValidBookmarksFileIsLoadedThenBookmarksImportIsSuccessful() { - let importExpectation = expectation(description: "Import Bookmarks") - let completionExpectation = expectation(description: "Import Bookmarks Completion") - let expectedImportResult = BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) - - underlyingBookmarkImporter.importBookmarks = { (_, _) in - importExpectation.fulfill() - return expectedImportResult + func testWhenValidBookmarksFileIsLoadedThenBookmarksImportIsSuccessful() async { + underlyingBookmarkImporter.importBookmarks = { (_, _) in + .init(successful: 42, duplicates: 2, failed: 3) } dataImporter = .init(fileURL: bookmarksFileURL("bookmarks_safari.html"), bookmarkImporter: underlyingBookmarkImporter) - dataImporter.importData(types: [.bookmarks], from: nil) { result in - switch result { - case let .success(summary): - XCTAssertEqual(summary, .init(bookmarksResult: expectedImportResult)) - default: - XCTFail("unexpected import error") - } - completionExpectation.fulfill() - } + let result = await dataImporter.importData(types: [.bookmarks]).task.value - waitForExpectations(timeout: 1) + XCTAssertEqual(result, [.bookmarks: .success(.init(successful: 42, duplicate: 2, failed: 3))]) } - func testWhenInvalidBookmarksFileIsLoadedThenBookmarksImportReturnsFailure() { - let completionExpectation = expectation(description: "Import Bookmarks Completion") - let expectedImportResult = BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) - + func testWhenInvalidBookmarksFileIsLoadedThenBookmarksImportReturnsFailure() async { underlyingBookmarkImporter.importBookmarks = { (_, _) in - XCTFail("unexpected import success") - return expectedImportResult + .init(successful: 0, duplicates: 0, failed: 0) } dataImporter = .init(fileURL: bookmarksFileURL("bookmarks_invalid.html"), bookmarkImporter: underlyingBookmarkImporter) - dataImporter.importData(types: [.bookmarks], from: nil) { result in - switch result { - case let .failure(error as BookmarkHTMLReader.ImportError): - XCTAssertEqual(error.type, .parseXml) - XCTAssertEqual((error.underlyingError as NSError?)?.domain, XMLParser.errorDomain) - default: - XCTFail("unexpected \(result)") - } - completionExpectation.fulfill() - } + let result = await dataImporter.importData(types: [.bookmarks]).task.value - waitForExpectations(timeout: 1) + XCTAssertEqual(result, [.bookmarks: .failure(BookmarkHTMLReader.ImportError(type: .parseXml, underlyingError: NSError(domain: XMLParser.errorDomain, code: XMLParser.ErrorCode.prematureDocumentEndError.rawValue)))]) } + } diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index c6732e548b..6cf5462994 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -132,7 +132,7 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "System Profile") - XCTAssertNil(profile.chromiumPreferences?.profileName) + XCTAssertEqual(profile.chromiumPreferences?.profileName, "ChromeProfile") } private func profile(named name: String) -> URL { diff --git a/UnitTests/DataImport/CSVImporterTests.swift b/UnitTests/DataImport/CSVImporterTests.swift index dc8b6f3061..e3d72e0c81 100644 --- a/UnitTests/DataImport/CSVImporterTests.swift +++ b/UnitTests/DataImport/CSVImporterTests.swift @@ -68,54 +68,26 @@ class CSVImporterTests: XCTestCase { XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) } - func testWhenImportingCSVDataFromTheFileSystem_AndNoTitleIsIncluded_ThenLoginCredentialsAreImported() { + func testWhenImportingCSVDataFromTheFileSystem_AndNoTitleIsIncluded_ThenLoginCredentialsAreImported() async { let mockLoginImporter = MockLoginImporter() let file = "https://example.com/,username,password" let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! - let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter) - - let expectation = expectation(description: #function) - csvImporter.importData(types: [.logins], from: nil) { result in - switch result { - case .success(let summary): - let expectedSummary = DataImport.Summary(bookmarksResult: nil, - loginsResult: .completed(.init(successfulImports: ["username"], - duplicateImports: [], - failedImports: []))) - XCTAssertEqual(summary, expectedSummary) - XCTAssertEqual(mockLoginImporter.importedLogins, expectedSummary) - case .failure(let error): - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - - waitForExpectations(timeout: 1.0, handler: nil) + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) } - func testWhenImportingCSVDataFromTheFileSystem_AndTitleIsIncluded_ThenLoginCredentialsAreImported() { + func testWhenImportingCSVDataFromTheFileSystem_AndTitleIsIncluded_ThenLoginCredentialsAreImported() async { let mockLoginImporter = MockLoginImporter() let file = "title,https://example.com/,username,password" let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! - let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter) - - let expectation = expectation(description: #function) - csvImporter.importData(types: [.logins], from: nil) { result in - switch result { - case .success(let summary): - let expectedSummary = DataImport.Summary(bookmarksResult: nil, - loginsResult: .completed(.init(successfulImports: ["username"], - duplicateImports: [], - failedImports: []))) - XCTAssertEqual(summary, expectedSummary) - XCTAssertEqual(mockLoginImporter.importedLogins, expectedSummary) - case .failure(let error): - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - - waitForExpectations(timeout: 1.0, handler: nil) + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) } func testWhenInferringColumnPostions_AndColumnsAreValid_AndTitleIsIncluded_ThenPositionsAreCalculated() { diff --git a/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift b/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift index 623a26be9f..577202c75f 100644 --- a/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift +++ b/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift @@ -23,7 +23,7 @@ import XCTest class ChromiumBookmarksReaderTests: XCTestCase { func testImportingBookmarks() { - let bookmarksReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: resourceURL(), source: .chrome) + let bookmarksReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: resourceURL()) let bookmarks = bookmarksReader.readBookmarks() guard case let .success(bookmarks) = bookmarks else { diff --git a/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift b/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift index e77edb0db2..1bf9e67935 100644 --- a/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift +++ b/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift @@ -23,7 +23,7 @@ import Foundation class ChromiumFaviconsReaderTests: XCTestCase { func testReadingFavicons() { - let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: resourceURL(), source: .chrome) + let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: resourceURL()) let favicons = faviconsReader.readFavicons() guard case let .success(favicons) = favicons else { diff --git a/UnitTests/DataImport/ChromiumLoginReaderTests.swift b/UnitTests/DataImport/ChromiumLoginReaderTests.swift index 1661808cfa..ba59c59bba 100644 --- a/UnitTests/DataImport/ChromiumLoginReaderTests.swift +++ b/UnitTests/DataImport/ChromiumLoginReaderTests.swift @@ -41,7 +41,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.v32.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKey: ChromiumLoginStore.v32.decryptionKey ) @@ -70,7 +69,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.legacy.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKey: ChromiumLoginStore.legacy.decryptionKey ) @@ -90,7 +88,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.legacy.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKeyPrompt: mockPrompt ) @@ -109,7 +106,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.legacy.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKeyPrompt: mockPrompt ) diff --git a/UnitTests/DataImport/DataImportMocks.swift b/UnitTests/DataImport/DataImportMocks.swift index 3f9453f41d..5bea21f230 100644 --- a/UnitTests/DataImport/DataImportMocks.swift +++ b/UnitTests/DataImport/DataImportMocks.swift @@ -21,12 +21,12 @@ import Foundation final class MockLoginImporter: LoginImporter { - var importedLogins: DataImport.Summary? + var importedLogins: DataImportSummary? - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.LoginsImportSummary { - let summary = DataImport.LoginsImportSummary(successfulImports: logins.map(\.username), duplicateImports: [], failedImports: []) + func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.DataTypeSummary { + let summary = DataImport.DataTypeSummary(successful: logins.count, duplicate: 0, failed: 0) - self.importedLogins = .init(bookmarksResult: nil, loginsResult: .completed(summary)) + self.importedLogins = [.passwords: .success(summary)] return summary } diff --git a/UnitTests/DataImport/FirefoxDataImporterTests.swift b/UnitTests/DataImport/FirefoxDataImporterTests.swift index 2bd64ff320..b472dd0de1 100644 --- a/UnitTests/DataImport/FirefoxDataImporterTests.swift +++ b/UnitTests/DataImport/FirefoxDataImporterTests.swift @@ -27,14 +27,14 @@ class FirefoxDataImporterTests: XCTestCase { let loginImporter = MockLoginImporter() let faviconManager = FaviconManagerMock() let bookmarkImporter = MockBookmarkImporter(importBookmarks: { _, _ in .init(successful: 1, duplicates: 2, failed: 3) }) - let importer = FirefoxDataImporter(profile: .init(browser: .firefox, profileURL: resourceURL()), loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) + let importer = FirefoxDataImporter(profile: .init(browser: .firefox, profileURL: resourceURL()), primaryPassword: nil, loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) let result = await importer.importData(types: [.bookmarks]) - XCTAssertNil(result.logins) - if case let .success(bookmarks) = result.bookmarks { + XCTAssertNil(result[.passwords]) + if case let .success(bookmarks) = result[.bookmarks] { XCTAssertEqual(bookmarks.successful, 1) - XCTAssertEqual(bookmarks.duplicates, 2) + XCTAssertEqual(bookmarks.duplicate, 2) XCTAssertEqual(bookmarks.failed, 3) } else { XCTFail("Received populated summary unexpectedly") @@ -48,11 +48,7 @@ class FirefoxDataImporterTests: XCTestCase { } extension FirefoxDataImporter { - func importData(types: Set) async -> DataImport.Summary { - return await withCheckedContinuation { continuation in - importData(types: types) { result in - continuation.resume(returning: result) - } - } + func importData(types: Set) async -> DataImportSummary { + return await importData(types: types).task.value } } diff --git a/UnitTests/DataImport/FirefoxLoginReaderTests.swift b/UnitTests/DataImport/FirefoxLoginReaderTests.swift index cc590fb06b..83abe67b66 100644 --- a/UnitTests/DataImport/FirefoxLoginReaderTests.swift +++ b/UnitTests/DataImport/FirefoxLoginReaderTests.swift @@ -132,7 +132,7 @@ class FirefoxLoginReaderTests: XCTestCase { switch result { case .failure(let error as FirefoxLoginReader.ImportError): - XCTAssertEqual(error.type, .couldNotFindLoginsFile) + XCTAssertEqual(error.type, .couldNotDetermineFormat) default: XCTFail("Received unexpected \(result)") } diff --git a/UnitTests/DataImport/ThirdPartyBrowserTests.swift b/UnitTests/DataImport/ThirdPartyBrowserTests.swift index e2a6d7b6b7..5c63638a14 100644 --- a/UnitTests/DataImport/ThirdPartyBrowserTests.swift +++ b/UnitTests/DataImport/ThirdPartyBrowserTests.swift @@ -76,10 +76,7 @@ class ThirdPartyBrowserTests: XCTestCase { let mockApplicationSupportDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(mockApplicationSupportDirectoryName) try mockApplicationSupportDirectory.writeToTemporaryDirectory() - guard let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) else { - XCTFail("Failed to get profile list") - return - } + let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) let validProfiles = list.profiles.filter { $0.validateProfileData()?.containsValidData == true } XCTAssertEqual(validProfiles.count, 2) @@ -102,10 +99,7 @@ class ThirdPartyBrowserTests: XCTestCase { let mockApplicationSupportDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(mockApplicationSupportDirectoryName) try mockApplicationSupportDirectory.writeToTemporaryDirectory() - guard let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) else { - XCTFail("Failed to get profile list") - return - } + let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) let validProfiles = list.profiles.filter { $0.validateProfileData()?.containsValidData == true } XCTAssertEqual(validProfiles.count, 1) diff --git a/UnitTests/HomePage/DataImportProviderTests.swift b/UnitTests/HomePage/DataImportProviderTests.swift index b2bc3e9271..72689aed8a 100644 --- a/UnitTests/HomePage/DataImportProviderTests.swift +++ b/UnitTests/HomePage/DataImportProviderTests.swift @@ -108,7 +108,8 @@ final class DataImportProviderTests: XCTestCase { } func testWhenNoPasswordsAndNoBookmarksDetectableAndSuccessImportThenDidImportIsTrue() { - DataImportViewController().successfulImportHappened = true + var model = DataImportViewModel() + model.successfulImportHappened = true XCTAssertTrue(provider.didImport) } diff --git a/UnitTests/YoutubePlayer/DuckPlayerTests.swift b/UnitTests/YoutubePlayer/DuckPlayerTests.swift index e87597ca27..ae6ac020b5 100644 --- a/UnitTests/YoutubePlayer/DuckPlayerTests.swift +++ b/UnitTests/YoutubePlayer/DuckPlayerTests.swift @@ -38,11 +38,11 @@ final class DuckPlayerTests: XCTestCase { let otherFaviconView = FaviconView(url: URL(string: "example.com")) duckPlayer.mode = .enabled - XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView), NSImage.duckPlayer) + XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView)?.tiffRepresentation, NSImage.duckPlayer.tiffRepresentation) XCTAssertNil(duckPlayer.image(for: otherFaviconView)) duckPlayer.mode = .alwaysAsk - XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView), NSImage.duckPlayer) + XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView)?.tiffRepresentation, NSImage.duckPlayer.tiffRepresentation) XCTAssertNil(duckPlayer.image(for: otherFaviconView)) duckPlayer.mode = .disabled From 32d92f42d7d7ae9a746a44192c63be87d940ff0e Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 21 Nov 2023 14:30:02 +0600 Subject: [PATCH 26/83] fix linter issues --- DuckDuckGo/DataImport/DataImport.swift | 2 +- DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift | 2 +- .../DataImport/Logins/Firefox/FirefoxLoginReader.swift | 3 +-- DuckDuckGo/DataImport/Model/DataImportReportModel.swift | 2 +- DuckDuckGo/DataImport/View/DataImportView.swift | 5 ++--- DuckDuckGo/DataImport/View/FileImportView.swift | 4 ++-- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 4a0cee045c..955bdbca02 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -482,7 +482,7 @@ enum DataImportResult { extension DataImportResult: Equatable where T: Equatable { static func == (lhs: DataImportResult, rhs: DataImportResult) -> Bool { switch lhs { - case .success(let value): + case .success(let value): if case .success(value) = rhs { true } else { diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index 3857011b72..8a2d23a3ca 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -199,7 +199,7 @@ final class CSVImporter: DataImporter { } func importData(types: Set) -> DataImportTask { - .detachedWithProgress { updateProgress in + .detachedWithProgress { _ in let result = self.importLoginsSync() return [.passwords: result] } diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift index 0e386591a3..2ffd85a665 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift @@ -105,7 +105,7 @@ final class FirefoxLoginReader { let databaseURL = firefoxProfileURL.appendingPathComponent(dataFormat.formatFileNames.databaseName) switch dataFormat { - case .version2: + case .version2: return try keyReader.getEncryptionKey(key3DatabaseURL: databaseURL, primaryPassword: primaryPassword ?? "").get() case .version3: return try keyReader.getEncryptionKey(key4DatabaseURL: databaseURL, primaryPassword: primaryPassword ?? "").get() @@ -115,7 +115,6 @@ final class FirefoxLoginReader { private func reallyReadLogins(dataFormat: DataFormat, keyData: Data, currentOperationType: inout ImportError.OperationType) throws -> [ImportedLoginCredential] { let loginsFileURL = firefoxProfileURL.appendingPathComponent(dataFormat.formatFileNames.loginsFileName) - currentOperationType = .couldNotReadLoginsFile let logins = try readLoginsFile(from: loginsFileURL.path) diff --git a/DuckDuckGo/DataImport/Model/DataImportReportModel.swift b/DuckDuckGo/DataImport/Model/DataImportReportModel.swift index c0eaaf4aef..41c8e03e81 100644 --- a/DuckDuckGo/DataImport/Model/DataImportReportModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportReportModel.swift @@ -26,7 +26,7 @@ struct DataImportReportModel { var importSource: DataImport.Source var importSourceVersion: String? - + var importSourceDescription: String { [importSource.importSourceName, importSourceVersion].compactMap { $0 }.joined(separator: " ") } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 2ecd349148..b1e7a4cc21 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -31,8 +31,7 @@ extension DataImportView { let sheetWindow = SheetHostingWindow(rootView: DataImportView()) - window.beginSheet(sheetWindow, completionHandler: completion.map { completion in - { _ in + window.beginSheet(sheetWindow, completionHandler: completion.map { completion in { _ in completion() } }) @@ -382,7 +381,7 @@ extension DataImportViewModel.ButtonType { return "password" } openPanelCallback: { _ in URL(fileURLWithPath: "/test/path") - } reportSenderFactory: { + } reportSenderFactory: { { feedback in print("send feedback:", feedback) } diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index f1efe09f6e..272c26ef69 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -144,7 +144,7 @@ struct FileImportView: View { If there is no header, it supports two formats: """ 1; "URL, Username, Password" - 2; "Title, URL, Username, Password"; + 2; "Title, URL, Username, Password" .button("Select Passwords CSV File…") @@ -287,7 +287,7 @@ private func buildInstructions(@FileImportInstructionsBuilder builder: () -> [Fi // zip [1, "text 1", 2, "text 2", "text 3"] to [[1, "text 1"], [2, "text 2"], ["text 3"]] var result: [[FileImportInstructionsItem]] = [] - var currentNumber: Int? = nil + var currentNumber: Int? for item in items { switch item { From e00d76b11cec98e5782874097f97cae67d69342b Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 21 Nov 2023 17:25:25 +0600 Subject: [PATCH 27/83] update CSV import source name --- DuckDuckGo/Common/Localizables/UserText.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 285c1e5628..20da91b520 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -502,7 +502,7 @@ struct UserText { // MARK: - Login Import & Export - static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Logins File", comment: "Title text for the CSV importer") + static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Passwords File", comment: "Title text for the CSV importer") static let importBookmarksHTML = NSLocalizedString("import.bookmarks.html.title", value: "HTML Bookmarks File", comment: "Title text for the HTML Bookmarks importer") static let importLoginsPasswords = NSLocalizedString("import.logins.passwords", value: "Passwords", comment: "Title text for the Passwords import option") From e64a46a13ac21dc2f64ed01fd1795940377a22b3 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 21 Nov 2023 17:25:35 +0600 Subject: [PATCH 28/83] fix build --- DuckDuckGo/DataImport/View/FileImportView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index 272c26ef69..a323e1fa10 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -144,7 +144,7 @@ struct FileImportView: View { If there is no header, it supports two formats: """ 1; "URL, Username, Password" - 2; "Title, URL, Username, Password" + 2; "Title, URL, Username, Password"; .button("Select Passwords CSV File…") From 1ed18deec9f7e7c48059d8c8ec70f8e982727129 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 21 Nov 2023 17:26:18 +0600 Subject: [PATCH 29/83] fix invalid profile folders shown for tor/firefox --- DuckDuckGo/DataImport/DataImport.swift | 6 +----- DuckDuckGo/DataImport/View/DataImportProfilePicker.swift | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 955bdbca02..9842ade917 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -178,10 +178,6 @@ enum DataImport { self.profiles = profiles } - var shouldShowProfilePicker: Bool { - return validImportableProfiles.count > 1 - } - var defaultProfile: BrowserProfile? { switch browser { case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: @@ -242,7 +238,7 @@ enum DataImport { let profileDirectoryContentsSet = Set(profileDirectoryContents) return .init(logins: validateLoginsData(profileDirectoryContents: profileDirectoryContentsSet), - bookmarks: validateBookmarksData(profileDirectoryContents: profileDirectoryContentsSet)) + bookmarks: validateBookmarksData(profileDirectoryContents: profileDirectoryContentsSet)) } private func validateLoginsData(profileDirectoryContents: Set) -> ProfileDataItemValidationResult { diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index bbaec6adab..804201bc0f 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -24,7 +24,7 @@ struct DataImportProfilePicker: View { @Binding private var selectedProfile: DataImport.BrowserProfile? init(profileList: DataImport.BrowserProfileList?, selectedProfile: Binding) { - self.profiles = profileList?.profiles ?? [] + self.profiles = profileList?.validImportableProfiles ?? [] self._selectedProfile = selectedProfile } From 76d28f0e4ab1faf9eab79af7b0b220dd6460708e Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 22 Nov 2023 16:21:39 +0600 Subject: [PATCH 30/83] allow file drop on file import button --- .../DataImport/View/DataImportView.swift | 2 + .../DataImport/View/FileImportView.swift | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index b1e7a4cc21..57a16f7485 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -102,6 +102,8 @@ struct DataImportView: View { // manual file import instructions for CSV/HTML FileImportView(source: viewModel.importSource, dataType: dataType, isButtonDisabled: viewModel.isSelectFileButtonDisabled) { viewModel.selectFile() + } onFileDrop: { url in + viewModel.initiateImport(fileURL: url) } case .fileImportSummary(let dataType): diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index a323e1fa10..3ebca1d271 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -16,21 +16,25 @@ // limitations under the License. // +import Common import SwiftUI +import UniformTypeIdentifiers struct FileImportView: View { let source: DataImport.Source let dataType: DataImport.DataType - let action: (() -> Void) + let action: () -> Void + let onFileDrop: (URL) -> Void private let instructions: [[FileImportInstructionsItem]] private var isButtonDisabled: Bool - init(source: DataImport.Source, dataType: DataImport.DataType, isButtonDisabled: Bool, action: (() -> Void)? = nil) { + init(source: DataImport.Source, dataType: DataImport.DataType, isButtonDisabled: Bool, action: (() -> Void)? = nil, onFileDrop: ((URL) -> Void)? = nil) { self.source = source self.dataType = dataType self.action = action ?? {} + self.onFileDrop = onFileDrop ?? { _ in } self.instructions = Self.instructions(for: source, dataType: dataType) self.isButtonDisabled = isButtonDisabled } @@ -191,6 +195,7 @@ struct FileImportView: View { Text(localizedStringKey) case .button(let localizedTitleKey): Button(localizedTitleKey, action: action) + .onDrop(of: dataType.allowedFileTypes, isTargeted: nil, perform: onDrop) .disabled(isButtonDisabled) } } @@ -199,6 +204,45 @@ struct FileImportView: View { } } + private func onDrop(_ providers: [NSItemProvider], _ location: CGPoint) -> Bool { + let allowedTypeIdentifiers = providers.reduce(into: Set()) { + $0.formUnion($1.registeredTypeIdentifiers) + }.intersection(dataType.allowedFileTypes.map(\.identifier)) + + guard let typeIdentifier = allowedTypeIdentifiers.first, + let provider = providers.first(where: { + $0.hasItemConformingToTypeIdentifier(typeIdentifier) + }) else { + os_log(.error, log: .dataImportExport, "invalid type identifiers: \(allowedTypeIdentifiers)") + return false + } + + provider.loadItem(forTypeIdentifier: typeIdentifier) { data, error in + guard let data else { + os_log(.error, log: .dataImportExport, "error loading \(typeIdentifier): \(error?.localizedDescription ?? "?")") + return + } + let url: URL + switch data { + case let value as URL: + url = value + case let data as Data: + guard let value = URL(dataRepresentation: data, relativeTo: nil) else { + os_log(.error, log: .dataImportExport, "could not decode data: \(data.debugDescription)") + return + } + url = value + default: + os_log(.error, log: .dataImportExport, "unsupported data: \(data)") + return + } + + onFileDrop(url) + } + + return true + } + } struct CircleNumberView: View { From 3e78e358eac32047a79657a86518927a595d174a Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 23 Nov 2023 13:13:16 +0600 Subject: [PATCH 31/83] code adjustment; handle chromium userDeniedKeychainPrompt --- .../Model/DataImportViewModel.swift | 41 ++++++------------- .../DataImport/View/DataImportErrorView.swift | 19 +++++++++ .../DataImport/View/DataImportView.swift | 23 +++++++++++ 3 files changed, 55 insertions(+), 28 deletions(-) create mode 100644 DuckDuckGo/DataImport/View/DataImportErrorView.swift diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 319b7d825e..1385d97ce4 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -165,7 +165,11 @@ struct DataImportViewModel { private mutating func handleErrors(_ summary: [DataType: any DataImportError]) -> Bool { for error in summary.values { switch error { - // TODO: Chrome user denied keychain prompt error + // chromium user denied keychain prompt error + case let error as ChromiumLoginReader.ImportError where error.type == .userDeniedKeychainPrompt: + // stay on the same screen + return true + // firefox passwords db is master-password protected: request password case let error as FirefoxLoginReader.ImportError where error.type == .requiresPrimaryPassword: @@ -225,7 +229,7 @@ struct DataImportViewModel { } /// Skip button press - @MainActor private mutating func skipImport() { + @MainActor mutating func skipImport() { self.screen = nextScreen(skip: true) } @@ -240,6 +244,12 @@ struct DataImportViewModel { self.initiateImport(fileURL: url) } + mutating func goBack() { + // reset to initial screen + screen = importSource.initialScreen + summary.removeAll() + } + func submitReport() { let sendReport = reportSenderFactory() sendReport(reportModel) @@ -543,9 +553,7 @@ extension DataImportViewModel { case .next(let screen): self.screen = screen case .back: - // reset to initial screen - screen = importSource.initialScreen - summary.removeAll() + goBack() case .initiateImport: initiateImport() @@ -588,26 +596,3 @@ extension DataImportViewModel { } } - -extension DataImportViewModel.ButtonType { - - var title: String { - switch self { - case .next: - UserText.next - case .initiateImport: - UserText.initiateImport - case .skip: - UserText.skipImport - case .cancel: - UserText.cancel - case .back: - UserText.navigateBack - case .done: - UserText.done - case .submit: - UserText.submitReport - } - } - -} diff --git a/DuckDuckGo/DataImport/View/DataImportErrorView.swift b/DuckDuckGo/DataImport/View/DataImportErrorView.swift new file mode 100644 index 0000000000..09c10c9257 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportErrorView.swift @@ -0,0 +1,19 @@ +// +// DataImportErrorView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 57a16f7485..ef3757b3c1 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -235,6 +235,29 @@ extension DataImportViewModel.ButtonType { } +extension DataImportViewModel.ButtonType { + + var title: String { + switch self { + case .next: + UserText.next + case .initiateImport: + UserText.initiateImport + case .skip: + UserText.skipImport + case .cancel: + UserText.cancel + case .back: + UserText.navigateBack + case .done: + UserText.done + case .submit: + UserText.submitReport + } + } + +} + #Preview { { final class PreviewPreferences: ObservableObject { From 627e442ae13e3af5adbed043fae72e6d835c0222 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 23 Nov 2023 13:18:52 +0600 Subject: [PATCH 32/83] rollback unneded pbxproj changes --- DuckDuckGo.xcodeproj/project.pbxproj | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index d6759c9c4d..eb27c8589b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -9983,7 +9983,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "if [ \"$ENABLE_PREVIEWS\" = \"YES\" ]; then exit 0; fi\n#./lint.sh\n"; + shellScript = "if [ \"$ENABLE_PREVIEWS\" = \"YES\" ]; then exit 0; fi\n./lint.sh\n"; }; B6BD8F0A2A260E5900B6A41F /* embed libswift_Concurrency.dylib */ = { isa = PBXShellScriptBuildPhase; @@ -14087,9 +14087,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 378B58CD295ECA75002C0CC0 /* DuckDuckGo.xcconfig */; buildSettings = { - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - "DEVELOPMENT_TEAM[sdk=macosx*]" = HKE973VLUW; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "MacOS Browser Product Review"; }; name = Review; }; From c726e5c593d8d764ae68a4696cee5fb41c188a74 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 23 Nov 2023 13:21:44 +0600 Subject: [PATCH 33/83] rollback failing test --- UnitTests/DataImport/BrowserProfileTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index 6cf5462994..f3b8ceed82 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -132,7 +132,7 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "System Profile") - XCTAssertEqual(profile.chromiumPreferences?.profileName, "ChromeProfile") + XCTAssertNil(profile.chromiumPreferences) } private func profile(named name: String) -> URL { From cf0b6fa20f1f5be0c9c40fa08ebb89bc50dd9358 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sat, 25 Nov 2023 00:14:50 +0600 Subject: [PATCH 34/83] Validate profiles data in profile picker --- .../Localizables/en.lproj/Localizable.strings | 1 - .../en.lproj/Localizable.stringsdict | 30 ------------------- DuckDuckGo/DataImport/DataImport.swift | 8 +++-- .../DataImport/Model/InstructionsParser.swift | 19 ++++++++++++ .../View/DataImportProfilePicker.swift | 2 +- .../DataImport/View/DataImportView.swift | 4 ++- DuckDuckGo/Localizable.xcstrings | 5 ++++ .../InstructionsFormatParserTests.swift | 19 ++++++++++++ 8 files changed, 53 insertions(+), 35 deletions(-) delete mode 100644 DuckDuckGo/Common/Localizables/en.lproj/Localizable.strings delete mode 100644 DuckDuckGo/Common/Localizables/en.lproj/Localizable.stringsdict create mode 100644 DuckDuckGo/DataImport/Model/InstructionsParser.swift create mode 100644 DuckDuckGo/Localizable.xcstrings create mode 100644 UnitTests/DataImport/InstructionsFormatParserTests.swift diff --git a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.strings b/DuckDuckGo/Common/Localizables/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.stringsdict b/DuckDuckGo/Common/Localizables/en.lproj/Localizable.stringsdict deleted file mode 100644 index 006d3e9b5a..0000000000 --- a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.stringsdict +++ /dev/null @@ -1,30 +0,0 @@ - - - - - StringKey - - NSStringLocalizedFormatKey - %#@VARIABLE@ - VARIABLE - - NSStringFormatSpecTypeKey - NSStringPluralRuleType - NSStringFormatValueTypeKey - - zero - - one - - two - - few - - many - - other - - - - - diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 9842ade917..27d9018ba8 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -169,13 +169,17 @@ enum DataImport { let browser: ThirdPartyBrowser let profiles: [BrowserProfile] + typealias ProfileDataValidator = (BrowserProfile) -> () -> BrowserProfile.ProfileDataValidationResult? + private let validateProfileData: ProfileDataValidator + var validImportableProfiles: [BrowserProfile] { - return profiles.filter { $0.validateProfileData()?.containsValidData == true } + return profiles.filter { validateProfileData($0)()?.containsValidData == true } } - init(browser: ThirdPartyBrowser, profiles: [BrowserProfile]) { + init(browser: ThirdPartyBrowser, profiles: [BrowserProfile], validateProfileData: @escaping ProfileDataValidator = BrowserProfile.validateProfileData) { self.browser = browser self.profiles = profiles + self.validateProfileData = validateProfileData } var defaultProfile: BrowserProfile? { diff --git a/DuckDuckGo/DataImport/Model/InstructionsParser.swift b/DuckDuckGo/DataImport/Model/InstructionsParser.swift new file mode 100644 index 0000000000..2323154d25 --- /dev/null +++ b/DuckDuckGo/DataImport/Model/InstructionsParser.swift @@ -0,0 +1,19 @@ +// +// InstructionsParser.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index 804201bc0f..0d7e256c5d 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -58,7 +58,7 @@ struct DataImportProfilePicker: View { profileURL: URL(fileURLWithPath: "/test/Profile 1")), .init(browser: .chrome, profileURL: URL(fileURLWithPath: "/test/Profile 2")), - ]), selectedProfile: Binding { + ], validateProfileData: { _ in { .init(logins: .available, bookmarks: .available) } }), selectedProfile: Binding { .init(browser: .chrome, profileURL: URL(fileURLWithPath: "/test/Profile 1")) } set: { diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index ef3757b3c1..800c52ab64 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -398,7 +398,9 @@ extension DataImportViewModel.ButtonType { profileURL: URL(fileURLWithPath: "/test/Profile 1")), .init(browser: .chrome, profileURL: URL(fileURLWithPath: "/test/Profile 2")), - ]) + ]) { _ in + { .init(logins: .available, bookmarks: .available) } + } } dataImporterFactory: { source, type, _, _ in return MockDataImporter(source: source, dataType: type) } requestPrimaryPasswordCallback: { _ in diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings new file mode 100644 index 0000000000..8a470103bc --- /dev/null +++ b/DuckDuckGo/Localizable.xcstrings @@ -0,0 +1,5 @@ +{ + "sourceLanguage" : "en", + "strings" : {}, + "version" : "1.0" +} diff --git a/UnitTests/DataImport/InstructionsFormatParserTests.swift b/UnitTests/DataImport/InstructionsFormatParserTests.swift new file mode 100644 index 0000000000..18d934d541 --- /dev/null +++ b/UnitTests/DataImport/InstructionsFormatParserTests.swift @@ -0,0 +1,19 @@ +// +// InstructionsFormatParserTests.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation From ff589e1b5b42aa585e6796c41e07b05d10faa1fd Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sat, 25 Nov 2023 00:23:37 +0600 Subject: [PATCH 35/83] adjust data import view after ship review --- .../View/DataImportProfilePicker.swift | 5 +- .../View/DataImportSourcePicker.swift | 5 +- .../DataImport/View/DataImportView.swift | 99 ++++++++++++------- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index 0d7e256c5d..c8b7cdb973 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -43,7 +43,8 @@ struct DataImportProfilePicker: View { Text(profiles[idx].profileName) } } label: {} - .pickerStyle(MenuPickerStyle()) + .pickerStyle(.menu) + .controlSize(.large) } } } @@ -64,4 +65,6 @@ struct DataImportProfilePicker: View { } set: { print("Profile selected:", $0?.profileURL.lastPathComponent ?? "") }) + .padding() + .frame(width: 512) } diff --git a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift index ac34184231..16b88a0b92 100644 --- a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift @@ -47,11 +47,12 @@ struct DataImportSourcePicker: View { } } } label: {} + .pickerStyle(.menu) + .controlSize(.large) .onChange(of: viewModel.selectedSourceIndex) { idx in guard let importSource = importSources[idx] else { return } viewModel.onSelectedSourceChanged(importSource) } - .pickerStyle(MenuPickerStyle()) } } @@ -60,4 +61,6 @@ struct DataImportSourcePicker: View { DataImportSourcePicker(selectedSource: .csv) { print("selection:", $0) } + .padding() + .frame(width: 500) } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 800c52ab64..0c2d48f760 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -39,6 +39,7 @@ extension DataImportView { } +@MainActor struct DataImportView: View { @Environment(\.dismiss) private var dismiss @@ -49,10 +50,34 @@ struct DataImportView: View { @State private var progressFraction: Double? var body: some View { + VStack(alignment: .leading, spacing: 0) { + viewHeader() + .padding(.top, 20) + .padding(.leading, 20) + .padding(.trailing, 20) + .padding(.bottom, 24) + + viewBody() + .padding(.leading, 20) + .padding(.trailing, 20) + .padding(.bottom, 32) + + Divider() + + viewFooter() + .padding(.top, 16) + .padding(.bottom, 16) + .padding(.trailing, 20) + } + .frame(width: 512) + .fixedSize() + } + + private func viewHeader() -> some View { VStack(alignment: .leading, spacing: 0) { Text("Import Browser Data") .font(.headline) - Spacer().frame(height: 10) + .padding(.bottom, 16) // browser to import data from picker popup if case .feedback = viewModel.screen {} else { @@ -61,18 +86,20 @@ struct DataImportView: View { } .disabled(viewModel.isImportSourcePickerDisabled) } + } + } - Spacer().frame(height: 16) - + // swiftlint:disable:next function_body_length + private func viewBody() -> some View { + VStack(alignment: .leading, spacing: 0) { // body switch viewModel.screen { case .profileAndDataTypesPicker: // Browser Profile picker DataImportProfilePicker(profileList: viewModel.browserProfiles, selectedProfile: $viewModel.selectedProfile) - .disabled(viewModel.isImportSourcePickerDisabled) - - Spacer().frame(height: 16) + .disabled(viewModel.isImportSourcePickerDisabled) + .padding(.bottom, 24) // Bookmarks/Passwords checkboxes DataImportTypePicker(viewModel: $viewModel) @@ -134,40 +161,40 @@ struct DataImportView: View { // Import in progress… if let importProgress = viewModel.importProgress { - Spacer().frame(height: 24) - - // Progress bar with label: Importing [bookmarks|passwords]… - ProgressView(value: progressFraction) { - Text(progressText ?? "") - } - .task { - // when viewModel.importProgress async sequence not nil - // receive progress updates events and update model on completion - await handleImportProgress(importProgress) - } - + progressView(importProgress) } + } + } - Spacer().frame(height: 32) - Divider() - Spacer().frame(height: 24) + private func progressView(_ progress: TaskProgress) -> some View { + // Progress bar with label: Importing [bookmarks|passwords]… + ProgressView(value: progressFraction) { + Text(progressText ?? "") + } + .padding(.top, 24) + .task { + // when viewModel.importProgress async sequence not nil + // receive progress updates events and update model on completion + await handleImportProgress(progress) + } + } - // under line buttons - HStack { - Spacer() + // under line buttons + private func viewFooter() -> some View { + HStack(spacing: 8) { + Spacer() - ForEach(viewModel.buttons, id: \.type) { button in - Button(button.type.title) { - viewModel.performAction(for: button.type, dismiss: dismiss.callAsFunction) - } - .keyboardShortcut(button.type.shortcut) - .disabled(button.isDisabled) + ForEach(viewModel.buttons, id: \.type) { button in + Button { + viewModel.performAction(for: button.type, dismiss: dismiss.callAsFunction) + } label: { + Text(button.type.title) + .frame(minWidth: 80 - 16 - 1) } + .keyboardShortcut(button.type.shortcut) + .disabled(button.isDisabled) } } - .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) - .frame(width: 512) - .fixedSize() } private func handleImportProgress(_ progress: TaskProgress) async { @@ -398,9 +425,9 @@ extension DataImportViewModel.ButtonType { profileURL: URL(fileURLWithPath: "/test/Profile 1")), .init(browser: .chrome, profileURL: URL(fileURLWithPath: "/test/Profile 2")), - ]) { _ in - { .init(logins: .available, bookmarks: .available) } - } + ], validateProfileData: { _ in + { .init(logins: .available, bookmarks: .available) } // swiftlint:disable:this opening_brace + }) } dataImporterFactory: { source, type, _, _ in return MockDataImporter(source: source, dataType: type) } requestPrimaryPasswordCallback: { _ in From be4cf26532be9f520a19631530b36928e3e9ce84 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sat, 25 Nov 2023 00:24:43 +0600 Subject: [PATCH 36/83] Localizable xcstrings --- Configuration/Global.xcconfig | 4 + DuckDuckGo.xcodeproj/project.pbxproj | 60 +- .../DataImport/Model/InstructionsParser.swift | 231 + .../DataImport/View/FileImportView.swift | 696 +- DuckDuckGo/Localizable.xcstrings | 8480 ++++++++++++++++- .../InstructionsFormatParserTests.swift | 96 + 6 files changed, 9307 insertions(+), 260 deletions(-) diff --git a/Configuration/Global.xcconfig b/Configuration/Global.xcconfig index 4ea29638f4..39c875ee65 100644 --- a/Configuration/Global.xcconfig +++ b/Configuration/Global.xcconfig @@ -104,3 +104,7 @@ OTHER_SWIFT_FLAGS[config=CI][arch=*][sdk=*] = $(inherited) $(DDG_SLOW_COMPILE_CH // Automatically generate Color and Image asset accessor extensions ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES + +// Automatically generate localization string catalogs +LOCALIZATION_PREFERS_STRING_CATALOGS = YES +SWIFT_EMIT_LOC_STRINGS = YES diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index eb27c8589b..5c810c2f00 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -752,7 +752,6 @@ 3192A2282A4C4CFF0084EA89 /* FindInPage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85A0117325AF2EDF00FA6A0C /* FindInPage.storyboard */; }; 3192A2292A4C4CFF0084EA89 /* JSAlert.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EEC111E3294D06020086524F /* JSAlert.storyboard */; }; 3192A22A2A4C4CFF0084EA89 /* HomePage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E7B27BBB8630038AD11 /* HomePage.storyboard */; }; - 3192A22B2A4C4CFF0084EA89 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC8B256C49B8007083E7 /* Localizable.strings */; }; 3192A22C2A4C4CFF0084EA89 /* userscript.js in Resources */ = {isa = PBXBuildFile; fileRef = B31055BE27A1BA1D001AC618 /* userscript.js */; }; 3192A22D2A4C4CFF0084EA89 /* fb-tds.json in Resources */ = {isa = PBXBuildFile; fileRef = EA4617EF273A28A700F110A2 /* fb-tds.json */; }; 3192A22E2A4C4CFF0084EA89 /* TabPreview.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AAE8B101258A41C000E81239 /* TabPreview.storyboard */; }; @@ -784,7 +783,6 @@ 3192A24C2A4C4CFF0084EA89 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E8E27BBBBF10038AD11 /* Main.storyboard */; }; 3192A24D2A4C4CFF0084EA89 /* social_images in Resources */ = {isa = PBXBuildFile; fileRef = EA18D1C9272F0DC8006DC101 /* social_images */; }; 3192A24E2A4C4CFF0084EA89 /* shield-dot-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E827E880A600036718 /* shield-dot-mouse-over.json */; }; - 3192A24F2A4C4CFF0084EA89 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC91256C49BC007083E7 /* Localizable.stringsdict */; }; 3192A2502A4C4CFF0084EA89 /* fb-sdk.js in Resources */ = {isa = PBXBuildFile; fileRef = EAC80DDF271F6C0100BBF02D /* fb-sdk.js */; }; 3192A2512A4C4CFF0084EA89 /* PasswordManager.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85625993269C8F9600EE44BC /* PasswordManager.storyboard */; }; 3192A2522A4C4CFF0084EA89 /* dark-flame-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E127E7D05500036718 /* dark-flame-mouse-over.json */; }; @@ -1337,7 +1335,6 @@ 3706FCBF293F65D500E42796 /* ContentOverlay.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */; }; 3706FCC0293F65D500E42796 /* FindInPage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85A0117325AF2EDF00FA6A0C /* FindInPage.storyboard */; }; 3706FCC1293F65D500E42796 /* HomePage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E7B27BBB8630038AD11 /* HomePage.storyboard */; }; - 3706FCC2293F65D500E42796 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC8B256C49B8007083E7 /* Localizable.strings */; }; 3706FCC3293F65D500E42796 /* userscript.js in Resources */ = {isa = PBXBuildFile; fileRef = B31055BE27A1BA1D001AC618 /* userscript.js */; }; 3706FCC4293F65D500E42796 /* fb-tds.json in Resources */ = {isa = PBXBuildFile; fileRef = EA4617EF273A28A700F110A2 /* fb-tds.json */; }; 3706FCC5293F65D500E42796 /* TabPreview.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AAE8B101258A41C000E81239 /* TabPreview.storyboard */; }; @@ -1369,7 +1366,6 @@ 3706FCE5293F65D500E42796 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E8E27BBBBF10038AD11 /* Main.storyboard */; }; 3706FCE6293F65D500E42796 /* social_images in Resources */ = {isa = PBXBuildFile; fileRef = EA18D1C9272F0DC8006DC101 /* social_images */; }; 3706FCE7293F65D500E42796 /* shield-dot-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E827E880A600036718 /* shield-dot-mouse-over.json */; }; - 3706FCE8293F65D500E42796 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC91256C49BC007083E7 /* Localizable.stringsdict */; }; 3706FCE9293F65D500E42796 /* fb-sdk.js in Resources */ = {isa = PBXBuildFile; fileRef = EAC80DDF271F6C0100BBF02D /* fb-sdk.js */; }; 3706FCEA293F65D500E42796 /* PasswordManager.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85625993269C8F9600EE44BC /* PasswordManager.storyboard */; }; 3706FCEB293F65D500E42796 /* dark-flame-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E127E7D05500036718 /* dark-flame-mouse-over.json */; }; @@ -2600,7 +2596,6 @@ 4B957BFC2AC7AE700062CA31 /* FindInPage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85A0117325AF2EDF00FA6A0C /* FindInPage.storyboard */; }; 4B957BFD2AC7AE700062CA31 /* JSAlert.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EEC111E3294D06020086524F /* JSAlert.storyboard */; }; 4B957BFE2AC7AE700062CA31 /* HomePage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E7B27BBB8630038AD11 /* HomePage.storyboard */; }; - 4B957BFF2AC7AE700062CA31 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC8B256C49B8007083E7 /* Localizable.strings */; }; 4B957C002AC7AE700062CA31 /* userscript.js in Resources */ = {isa = PBXBuildFile; fileRef = B31055BE27A1BA1D001AC618 /* userscript.js */; }; 4B957C012AC7AE700062CA31 /* fb-tds.json in Resources */ = {isa = PBXBuildFile; fileRef = EA4617EF273A28A700F110A2 /* fb-tds.json */; }; 4B957C022AC7AE700062CA31 /* TabPreview.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AAE8B101258A41C000E81239 /* TabPreview.storyboard */; }; @@ -2632,7 +2627,6 @@ 4B957C1E2AC7AE700062CA31 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E8E27BBBBF10038AD11 /* Main.storyboard */; }; 4B957C1F2AC7AE700062CA31 /* social_images in Resources */ = {isa = PBXBuildFile; fileRef = EA18D1C9272F0DC8006DC101 /* social_images */; }; 4B957C202AC7AE700062CA31 /* shield-dot-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E827E880A600036718 /* shield-dot-mouse-over.json */; }; - 4B957C212AC7AE700062CA31 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC91256C49BC007083E7 /* Localizable.stringsdict */; }; 4B957C222AC7AE700062CA31 /* fb-sdk.js in Resources */ = {isa = PBXBuildFile; fileRef = EAC80DDF271F6C0100BBF02D /* fb-sdk.js */; }; 4B957C232AC7AE700062CA31 /* PasswordManager.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85625993269C8F9600EE44BC /* PasswordManager.storyboard */; }; 4B957C242AC7AE700062CA31 /* dark-flame-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E127E7D05500036718 /* dark-flame-mouse-over.json */; }; @@ -3158,8 +3152,6 @@ AA80EC67256C4691007083E7 /* BrowserTab.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC69256C4691007083E7 /* BrowserTab.storyboard */; }; AA80EC73256C46A2007083E7 /* Suggestion.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC75256C46A2007083E7 /* Suggestion.storyboard */; }; AA80EC79256C46AA007083E7 /* TabBar.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC7B256C46AA007083E7 /* TabBar.storyboard */; }; - AA80EC89256C49B8007083E7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC8B256C49B8007083E7 /* Localizable.strings */; }; - AA80EC8F256C49BC007083E7 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC91256C49BC007083E7 /* Localizable.stringsdict */; }; AA840A9827319D1600E63CDD /* FirePopoverWrapperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA840A9727319D1600E63CDD /* FirePopoverWrapperViewController.swift */; }; AA88D14B252A557100980B4E /* URLRequestExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA88D14A252A557100980B4E /* URLRequestExtension.swift */; }; AA8EDF2424923E980071C2E8 /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8EDF2324923E980071C2E8 /* URLExtension.swift */; }; @@ -3397,6 +3389,10 @@ B65783E725F8AAFB00D8DB33 /* String+Punycode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65783E625F8AAFB00D8DB33 /* String+Punycode.swift */; }; B657841A25FA484B00D8DB33 /* NSException+Catch.m in Sources */ = {isa = PBXBuildFile; fileRef = B657841925FA484B00D8DB33 /* NSException+Catch.m */; }; B657841F25FA497600D8DB33 /* NSException+Catch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657841E25FA497600D8DB33 /* NSException+Catch.swift */; }; + B658BAB62B0F845D00D1F2C7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */; }; + B658BAB72B0F848D00D1F2C7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */; }; + B658BAB82B0F849000D1F2C7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */; }; + B658BAB92B0F849100D1F2C7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */; }; B65DA5EF2A77CC3A00CBEE8D /* Bundle+NetworkProtectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B602E81F2A1E2603006D261F /* Bundle+NetworkProtectionExtensions.swift */; }; B65DA5F02A77CC3C00CBEE8D /* Bundle+NetworkProtectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B602E81F2A1E2603006D261F /* Bundle+NetworkProtectionExtensions.swift */; }; B65DA5F12A77D2BC00CBEE8D /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106B9D26A565DA0013B453 /* BundleExtension.swift */; }; @@ -3406,6 +3402,12 @@ B65DA5F52A77D3FA00CBEE8D /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106B9D26A565DA0013B453 /* BundleExtension.swift */; }; B65E6B9E26D9EC0800095F96 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65E6B9D26D9EC0800095F96 /* CircularProgressView.swift */; }; B65E6BA026D9F10600095F96 /* NSBezierPathExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */; }; + B6619EF62B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */; }; + B6619EF72B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */; }; + B6619EFB2B111CC500CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; + B6619EFC2B111CC600CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; + B6619EFD2B111CCA00CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; + B6619EFE2B111CCC00CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; B66260DD29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260DE29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260E029AC6EBD00E9E3EE /* HistoryTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */; }; @@ -4775,8 +4777,11 @@ B657841825FA484B00D8DB33 /* NSException+Catch.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSException+Catch.h"; sourceTree = ""; }; B657841925FA484B00D8DB33 /* NSException+Catch.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSException+Catch.m"; sourceTree = ""; }; B657841E25FA497600D8DB33 /* NSException+Catch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSException+Catch.swift"; sourceTree = ""; }; + B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B65E6B9D26D9EC0800095F96 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSBezierPathExtension.swift; sourceTree = ""; }; + B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsFormatParserTests.swift; sourceTree = ""; }; + B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsParser.swift; sourceTree = ""; }; B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationProtectionTabExtension.swift; sourceTree = ""; }; B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryTabExtension.swift; sourceTree = ""; }; B66260E529ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHotkeyHandler.swift; sourceTree = ""; }; @@ -6161,6 +6166,7 @@ 4B723E0126B0003E00E14D75 /* CSVImporterTests.swift */, 4B723E0026B0003E00E14D75 /* CSVParserTests.swift */, 4B723DFF26B0003E00E14D75 /* DataImportMocks.swift */, + B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */, 4BB99D0D26FE1A83001E4761 /* FirefoxBookmarksReaderTests.swift */, 4B98D27B28D960DD003C2B6F /* FirefoxFaviconsReaderTests.swift */, 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */, @@ -7153,6 +7159,7 @@ AA585D80248FD31100E9A3E2 /* DuckDuckGo */ = { isa = PBXGroup; children = ( + B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */, 3192EC862A4DCF0E001E97A5 /* DBP */, EEAEA3F4294D05CF00D04DF3 /* JSAlert */, B31055BB27A1BA0E001AC618 /* Autoconsent */, @@ -7481,8 +7488,6 @@ children = ( AA80EC53256BE3BC007083E7 /* UserText.swift */, 4B4D60D22A0C84F700BCD287 /* UserText+NetworkProtection.swift */, - AA80EC8B256C49B8007083E7 /* Localizable.strings */, - AA80EC91256C49BC007083E7 /* Localizable.stringsdict */, ); path = Localizables; sourceTree = ""; @@ -8495,6 +8500,7 @@ B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */, B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */, B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */, + B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */, ); path = Model; sourceTree = ""; @@ -9287,7 +9293,6 @@ 3192A2282A4C4CFF0084EA89 /* FindInPage.storyboard in Resources */, 3192A2292A4C4CFF0084EA89 /* JSAlert.storyboard in Resources */, 3192A22A2A4C4CFF0084EA89 /* HomePage.storyboard in Resources */, - 3192A22B2A4C4CFF0084EA89 /* Localizable.strings in Resources */, 3192A22C2A4C4CFF0084EA89 /* userscript.js in Resources */, 3192A22D2A4C4CFF0084EA89 /* fb-tds.json in Resources */, 3192A22E2A4C4CFF0084EA89 /* TabPreview.storyboard in Resources */, @@ -9309,6 +9314,7 @@ 3192A2402A4C4CFF0084EA89 /* trackers-1.json in Resources */, 3192A2412A4C4CFF0084EA89 /* dark-trackers-1.json in Resources */, 3192A2422A4C4CFF0084EA89 /* Feedback.storyboard in Resources */, + B658BAB82B0F849000D1F2C7 /* Localizable.xcstrings in Resources */, 3192A2442A4C4CFF0084EA89 /* BookmarkTableCellView.xib in Resources */, 3192A2452A4C4CFF0084EA89 /* HomePageAssets.xcassets in Resources */, 3192A2462A4C4CFF0084EA89 /* shield-mouse-over.json in Resources */, @@ -9320,7 +9326,6 @@ 3192A24C2A4C4CFF0084EA89 /* Main.storyboard in Resources */, 3192A24D2A4C4CFF0084EA89 /* social_images in Resources */, 3192A24E2A4C4CFF0084EA89 /* shield-dot-mouse-over.json in Resources */, - 3192A24F2A4C4CFF0084EA89 /* Localizable.stringsdict in Resources */, 3192A2502A4C4CFF0084EA89 /* fb-sdk.js in Resources */, 3192A2512A4C4CFF0084EA89 /* PasswordManager.storyboard in Resources */, 3192A2522A4C4CFF0084EA89 /* dark-flame-mouse-over.json in Resources */, @@ -9362,7 +9367,6 @@ 3706FCC0293F65D500E42796 /* FindInPage.storyboard in Resources */, EEC8EB3F2982CA440065AA39 /* JSAlert.storyboard in Resources */, 3706FCC1293F65D500E42796 /* HomePage.storyboard in Resources */, - 3706FCC2293F65D500E42796 /* Localizable.strings in Resources */, 3706FCC3293F65D500E42796 /* userscript.js in Resources */, 3706FCC4293F65D500E42796 /* fb-tds.json in Resources */, 3706FCC5293F65D500E42796 /* TabPreview.storyboard in Resources */, @@ -9383,6 +9387,7 @@ 3706FCD9293F65D500E42796 /* trackers-1.json in Resources */, 3706FCDA293F65D500E42796 /* dark-trackers-1.json in Resources */, 3706FCDB293F65D500E42796 /* Feedback.storyboard in Resources */, + B658BAB72B0F848D00D1F2C7 /* Localizable.xcstrings in Resources */, 3706FCDD293F65D500E42796 /* BookmarkTableCellView.xib in Resources */, 3706FCDE293F65D500E42796 /* HomePageAssets.xcassets in Resources */, 3706FCDF293F65D500E42796 /* shield-mouse-over.json in Resources */, @@ -9394,7 +9399,6 @@ 3706FCE5293F65D500E42796 /* Main.storyboard in Resources */, 3706FCE6293F65D500E42796 /* social_images in Resources */, 3706FCE7293F65D500E42796 /* shield-dot-mouse-over.json in Resources */, - 3706FCE8293F65D500E42796 /* Localizable.stringsdict in Resources */, 3706FCE9293F65D500E42796 /* fb-sdk.js in Resources */, 3706FCEA293F65D500E42796 /* PasswordManager.storyboard in Resources */, 3706FCEB293F65D500E42796 /* dark-flame-mouse-over.json in Resources */, @@ -9489,7 +9493,6 @@ 4B957BFC2AC7AE700062CA31 /* FindInPage.storyboard in Resources */, 4B957BFD2AC7AE700062CA31 /* JSAlert.storyboard in Resources */, 4B957BFE2AC7AE700062CA31 /* HomePage.storyboard in Resources */, - 4B957BFF2AC7AE700062CA31 /* Localizable.strings in Resources */, 4B957C002AC7AE700062CA31 /* userscript.js in Resources */, 4B957C012AC7AE700062CA31 /* fb-tds.json in Resources */, 4B957C022AC7AE700062CA31 /* TabPreview.storyboard in Resources */, @@ -9510,6 +9513,7 @@ 4B957C122AC7AE700062CA31 /* trackers-1.json in Resources */, 4B957C132AC7AE700062CA31 /* dark-trackers-1.json in Resources */, 4B957C142AC7AE700062CA31 /* Feedback.storyboard in Resources */, + B658BAB92B0F849100D1F2C7 /* Localizable.xcstrings in Resources */, 4B957C162AC7AE700062CA31 /* BookmarkTableCellView.xib in Resources */, 4B957C172AC7AE700062CA31 /* HomePageAssets.xcassets in Resources */, 4B957C182AC7AE700062CA31 /* shield-mouse-over.json in Resources */, @@ -9521,7 +9525,6 @@ 4B957C1E2AC7AE700062CA31 /* Main.storyboard in Resources */, 4B957C1F2AC7AE700062CA31 /* social_images in Resources */, 4B957C202AC7AE700062CA31 /* shield-dot-mouse-over.json in Resources */, - 4B957C212AC7AE700062CA31 /* Localizable.stringsdict in Resources */, 4B957C222AC7AE700062CA31 /* fb-sdk.js in Resources */, 4B957C232AC7AE700062CA31 /* PasswordManager.storyboard in Resources */, 4B957C242AC7AE700062CA31 /* dark-flame-mouse-over.json in Resources */, @@ -9595,7 +9598,6 @@ 85A0117425AF2EDF00FA6A0C /* FindInPage.storyboard in Resources */, EEC111E4294D06020086524F /* JSAlert.storyboard in Resources */, 85589E8127BBB8630038AD11 /* HomePage.storyboard in Resources */, - AA80EC89256C49B8007083E7 /* Localizable.strings in Resources */, B31055C627A1BA1D001AC618 /* userscript.js in Resources */, EA4617F0273A28A700F110A2 /* fb-tds.json in Resources */, AAE8B102258A41C000E81239 /* TabPreview.storyboard in Resources */, @@ -9616,6 +9618,7 @@ AA3439792754D55100B241FA /* trackers-1.json in Resources */, AA34397C2754D55100B241FA /* dark-trackers-1.json in Resources */, AA3863C527A1E28F00749AB5 /* Feedback.storyboard in Resources */, + B658BAB62B0F845D00D1F2C7 /* Localizable.xcstrings in Resources */, 4B92929026670D1700AD2C21 /* BookmarkTableCellView.xib in Resources */, 85AC7AD927BD625000FFB69B /* HomePageAssets.xcassets in Resources */, AA7EB6E727E8809D00036718 /* shield-mouse-over.json in Resources */, @@ -9627,7 +9630,6 @@ 85589E8F27BBBBF10038AD11 /* Main.storyboard in Resources */, EA18D1CA272F0DC8006DC101 /* social_images in Resources */, AA7EB6E927E880A600036718 /* shield-dot-mouse-over.json in Resources */, - AA80EC8F256C49BC007083E7 /* Localizable.stringsdict in Resources */, EAC80DE0271F6C0100BBF02D /* fb-sdk.js in Resources */, 85625994269C8F9600EE44BC /* PasswordManager.storyboard in Resources */, AA7EB6E327E7D05500036718 /* dark-flame-mouse-over.json in Resources */, @@ -10189,6 +10191,7 @@ 3192A00D2A4C4CFF0084EA89 /* ChromiumDataImporter.swift in Sources */, 3192A00F2A4C4CFF0084EA89 /* WKBackForwardListItemViewModel.swift in Sources */, 1D76760E2A9CE4F000DA0BD7 /* SupportedOsChecker.swift in Sources */, + B6619EFD2B111CCA00CD9186 /* InstructionsParser.swift in Sources */, 3192A0102A4C4CFF0084EA89 /* BWNotRespondingAlert.swift in Sources */, 3192A0112A4C4CFF0084EA89 /* DebugUserScript.swift in Sources */, 3192A0122A4C4CFF0084EA89 /* RecentlyClosedTab.swift in Sources */, @@ -11239,6 +11242,7 @@ 3706FC06293F65D500E42796 /* OnboardingViewModel.swift in Sources */, 3706FC07293F65D500E42796 /* ScriptSourceProviding.swift in Sources */, 4B6785402AA7C726008A5004 /* DailyPixel.swift in Sources */, + B6619EFC2B111CC600CD9186 /* InstructionsParser.swift in Sources */, 3706FC08293F65D500E42796 /* CoreDataBookmarkImporter.swift in Sources */, 3706FC09293F65D500E42796 /* SuggestionViewModel.swift in Sources */, 3706FC0A293F65D500E42796 /* BookmarkManagedObject.swift in Sources */, @@ -11512,6 +11516,7 @@ 3706FE1E293F661700E42796 /* GeolocationProviderTests.swift in Sources */, 3706FE1F293F661700E42796 /* AppStateChangePublisherTests.swift in Sources */, 3706FE20293F661700E42796 /* CLLocationManagerMock.swift in Sources */, + B6619EF72B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */, 3706FE21293F661700E42796 /* DownloadsPreferencesTests.swift in Sources */, 3706FE22293F661700E42796 /* FireproofDomainsTests.swift in Sources */, 3706FE23293F661700E42796 /* SuggestionLoadingMock.swift in Sources */, @@ -12376,6 +12381,7 @@ 4B957B452AC7AE700062CA31 /* BookmarkHTMLReader.swift in Sources */, 4B957B462AC7AE700062CA31 /* Tab+NSSecureCoding.swift in Sources */, 4B957B472AC7AE700062CA31 /* NSNotificationName+EmailManager.swift in Sources */, + B6619EFE2B111CCC00CD9186 /* InstructionsParser.swift in Sources */, 4B957B482AC7AE700062CA31 /* MouseOverButton.swift in Sources */, 4B957B492AC7AE700062CA31 /* FireInfoViewController.swift in Sources */, 4B957B4A2AC7AE700062CA31 /* LoginItem+NetworkProtection.swift in Sources */, @@ -12627,6 +12633,7 @@ 1D43EB3A292B63B00065E5D6 /* BWRequest.swift in Sources */, B68458CD25C7EB9000DC17B6 /* WKWebViewConfigurationExtensions.swift in Sources */, 85AC7ADD27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift in Sources */, + B6619EFB2B111CC500CD9186 /* InstructionsParser.swift in Sources */, AAC30A26268DFEE200D2D9CD /* CrashReporter.swift in Sources */, B60D64492AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */, 3184AC6D288F29D800C35E4B /* BadgeNotificationAnimationModel.swift in Sources */, @@ -13267,6 +13274,7 @@ AAEC74BC2642F0F800C2EFBC /* History.xcdatamodeld in Sources */, 56B234BF2A84EFD200F2A1CC /* NavigationBarUrlExtensionsTests.swift in Sources */, 37534C9E28104D9B002621E7 /* TabLazyLoaderTests.swift in Sources */, + B6619EF62B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */, 569277C429DEE09D00B633EF /* ContinueSetUpModelTests.swift in Sources */, 85F1B0C925EF9759004792B6 /* URLEventHandlerTests.swift in Sources */, 4B9292BD2667103100AD2C21 /* BookmarkOutlineViewDataSourceTests.swift in Sources */, @@ -13588,22 +13596,6 @@ name = TabBar.storyboard; sourceTree = ""; }; - AA80EC8B256C49B8007083E7 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - AA80EC8A256C49B8007083E7 /* en */, - ); - name = Localizable.strings; - sourceTree = ""; - }; - AA80EC91256C49BC007083E7 /* Localizable.stringsdict */ = { - isa = PBXVariantGroup; - children = ( - AA80EC90256C49BC007083E7 /* en */, - ); - name = Localizable.stringsdict; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/DuckDuckGo/DataImport/Model/InstructionsParser.swift b/DuckDuckGo/DataImport/Model/InstructionsParser.swift index 2323154d25..e42369eac2 100644 --- a/DuckDuckGo/DataImport/Model/InstructionsParser.swift +++ b/DuckDuckGo/DataImport/Model/InstructionsParser.swift @@ -16,4 +16,235 @@ // limitations under the License. // +import Common import Foundation + +struct InstructionsFormatParser { + + enum FormatComponent: Equatable { + case text(String, bold: Bool = false, italic: Bool = false) + case number + case string(bold: Bool = false, italic: Bool = false) + case object + + var isNumber: Bool { + if case .number = self { true } else { false } + } + } + + struct ParseError: Error, LocalizedError { + enum ErrorType: Error { + case unexpectedEndOfText, unexpectedEndOfLine, unexpectedEscapedCharacter, escapedDigitExpected + } + + let type: ErrorType + + let format: String + let position: Int + let afterText: String + + var errorDescription: String? { + switch type { + case .unexpectedEndOfText: + "Unexpected end of text: expected escaped character after “\(afterText)” in pattern “\(format)”" + case .unexpectedEndOfLine: + "Unexpected end of line: expected escaped character after “\(afterText)” in pattern “\(format)”" + case .unexpectedEscapedCharacter: + "Unexpected escaped character “\(afterText.last ?? "?")” after “\(afterText)”. Supported escape sequences are: %d - line number, %s - variable string expression, %@ - image or buttom. In pattern “\(format)”" + case .escapedDigitExpected: + "Expected %ld or %lld, got “\(afterText)” in pattern “\(format)”" + } + } + + } + + func parse(format: String) throws -> [[FormatComponent]] { + var parser = Parser() + + // TODO: pull this into parser instead + let format = format.replacing(regex("(%)\\d+\\$(\\S)"), with: "$1$2") + + var idx: Int! + do { + for (index, character) in format.enumerated() { + idx = index + try parser.accept(character) + } + + try parser.accept(nil) + } catch let errorType as ParseError.ErrorType { + throw ParseError(type: errorType, format: format, position: idx + 1, + afterText: String(format[format.index(format.startIndex, offsetBy: idx - min(10, idx))...format.index(format.startIndex, offsetBy: idx)])) + } + + return parser.result + } + + private struct Parser { + var delimiter: Character? + + var result: [[FormatComponent]] = [[]] + + var currentLiteral = "" + var currentEscapeSequence = "" + var isBold = false + var isItalic: Int = 0 + + @inline(__always) mutating func append(_ character: Character) { + currentLiteral.append(character) + currentEscapeSequence = "" + } + + @inline(__always) mutating func append(_ component: FormatComponent) { + if case .text = component {} else { + flushField() + } + result[result.endIndex - 1].append(component) + currentEscapeSequence = "" + } + + @inline(__always) mutating func flushField() { + if !currentLiteral.isEmpty { + append(.text(currentLiteral, bold: isBold, italic: isItalic > 0)) + currentLiteral = "" + } + currentEscapeSequence = "" + } + + @inline(__always) mutating func nextLine() { + flushField() + result.append([]) + currentEscapeSequence = "" + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + mutating func accept(_ character: Character?) throws { + switch (currentEscapeSequence, character) { + case ("", "%"): + currentEscapeSequence.append("%") + + case ("%", "s"): + append(.string(bold: isBold, italic: isItalic > 0)) + + case ("%", "@"): + append(.object) + + case ("%", "l"), ("%l", "l"): + currentEscapeSequence.append("l") + case ("%", "d"), ("%l", "d"), ("%ll", "d"): + append(.number) + + case ("%", "%"): + currentEscapeSequence = "" + append("%") + + case ("%", _): + throw ParseError.ErrorType.unexpectedEscapedCharacter + case ("%l", _), ("%ll", _): + throw ParseError.ErrorType.escapedDigitExpected + + case ("", "*"), + ("*", "*"): + currentEscapeSequence.append("*") + + case ("*", _): // only one `*` – reset and recurse + append("*") + try accept(character) + + // " " follows ** - reset and recurse + case ("**", .some(let character)) where !character.isWordChar && !isBold: + append("*") + append("*") + try accept(character) + + case ("**", _): + flushField() + isBold.toggle() + try accept(character) + + case ("", "_"), + ("_", "_"): + currentEscapeSequence.append("_") + + // one "_" followed by non-alphanumeric – reset and recurse + case ("_", .some(let character)) where !character.isWordChar && isItalic == 0: + append("_") + try accept(character) + + // one "_" followed by non-alphanumeric when italic == 1: toggle italic + case ("_", .some(let character)) where !character.isWordChar && isItalic == 1: + flushField() + isItalic = 0 + try accept(character) + + case ("_", _) where isItalic == 0 && currentLiteral.last?.isWordChar != true: + // " _word" - italic start + flushField() + isItalic = 1 + try accept(character) + + case ("_", _): // word continues after dash + append("_") + try accept(character) + + // " " follows __ - reset and recurse + case ("__", .some(let character)) where !character.isWordChar && isItalic == 0: + append("_") + append("_") + try accept(character) + + case ("__", _) where isItalic == 0: + flushField() + isItalic = 2 + try accept(character) + + case ("__", _) where isItalic == 2: + flushField() + isItalic = 0 + try accept(character) + + case (_, "\n"): + if currentEscapeSequence.hasPrefix("%") { + throw ParseError.ErrorType.unexpectedEndOfLine + } + for character in currentEscapeSequence { + append(character) + } + + nextLine() + + case (_, " ") where currentLiteral.isEmpty && (result[result.endIndex - 1].last?.isNumber ?? true): + // trim whitespace after number or for a new line + break + + case (_, .some(let character)): + currentLiteral.append(character) + + case (_, .none): + if currentEscapeSequence.hasPrefix("%") { + throw ParseError.ErrorType.unexpectedEndOfText + } + for character in currentEscapeSequence { + append(character) + } + + flushField() + } + } + } + +} + +private extension Character { + + var isWordChar: Bool { + CharacterSet.wordCharacters.contains(unicodeScalars.first!) + } + +} + +private extension CharacterSet { + + static let wordCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "%.-")) + +} diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index 3ebca1d271..b6c0a5ef20 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -26,7 +26,6 @@ struct FileImportView: View { let dataType: DataImport.DataType let action: () -> Void let onFileDrop: (URL) -> Void - private let instructions: [[FileImportInstructionsItem]] private var isButtonDisabled: Bool @@ -35,145 +34,9 @@ struct FileImportView: View { self.dataType = dataType self.action = action ?? {} self.onFileDrop = onFileDrop ?? { _ in } - self.instructions = Self.instructions(for: source, dataType: dataType) self.isButtonDisabled = isButtonDisabled } - // swiftlint:disable:next function_body_length - private static func instructions(for source: DataImport.Source, dataType: DataImport.DataType) -> [[FileImportInstructionsItem]] { - buildInstructions { - switch (source, dataType) { - case (.brave, .passwords), - (.chrome, .passwords), - (.chromium, .passwords), - (.coccoc, .passwords), - (.edge, .passwords), - (.vivaldi, .passwords), - (.opera, .passwords), - (.operaGX, .passwords): - - 1; "Open **\(source.importSourceName)**" - 2; "In a fresh tab, click \(Image(.menuVertical16)) then **\(source == .chrome ? "Google " : "")Password Manager → Settings**" - 3; "Find “Export Passwords” and click **Download File**" - 4; "Save the passwords file someplace you can find it (e.g. Desktop)" - 5; .button("Select Passwords CSV File…") - - case (.yandex, .passwords): - 1; "Open **Yandex**" - 2; "Click \(Image(.menuHamburger16)) to open the application menu then click **Passwords and cards**" - 3; "Click \(Image(.menuVertical16)) then **Export passwords**" - 4; "Choose **To a text file (not secure)** and click **Export**" - 5; "Save the passwords file someplace you can find it (e.g. Desktop)" - 6; .button("Select Passwords CSV File…") - - case (.brave, .bookmarks), - (.chrome, .bookmarks), - (.chromium, .bookmarks), - (.coccoc, .bookmarks), - (.edge, .bookmarks), - (.vivaldi, .bookmarks), - (.opera, .bookmarks), - (.operaGX, .bookmarks): - 1; "Open **\(source.importSourceName)**" - 2; "Use the Menu Bar to select **Bookmarks → Bookmark Manager**" - 3; "Click \(Image(.menuVertical16)) then **Export Bookmarks**" - 4; "Save the file someplace you can find it (e.g., Desktop)" - 5; .button("Select Bookmarks HTML File…") - - case (.yandex, .bookmarks): - 1; "Open **\(source.importSourceName)**" - 2; "Use the Menu Bar to select **Favorites → Bookmark Manager**" - 3; "Click \(Image(.menuVertical16)) then **Export bookmarks to HTML file**" - 4; "Save the file someplace you can find it (e.g., Desktop)" - 5; .button("Select Bookmarks HTML File…") - case (.safari, .passwords), (.safariTechnologyPreview, .passwords): - 1; "Open **Safari**" - 2; "Select **File → Export → Passwords**" - 3; "Save the passwords file someplace you can find it (e.g. Desktop)" - 4; .button("Select Passwords CSV File…") - - case (.safari, .bookmarks), (.safariTechnologyPreview, .bookmarks): - 1; "Open **Safari**" - 2; "Select **File → Export → Bookmarks**" - 3; "Save the passwords file someplace you can find it (e.g. Desktop)" - 4; .button("Select Bookmarks HTML File…") - - case (.firefox, .passwords): - 1; "Open **\(source.importSourceName)**" - 2; "Click \(Image(.menuHamburger16)) to open the application menu then click **Passwords**" - 3; "Click \(Image(.menuVertical16)) then **Export Logins…**" - 4; "Save the passwords file someplace you can find it (e.g. Desktop)" - 5; .button("Select Passwords CSV File…") - - case (.firefox, .bookmarks), (.tor, .bookmarks): - 1; "Open **\(source.importSourceName)**" - 2; "Use the Menu Bar to select **Bookmarks → Manage Bookmarks**" - 3; "Click \(Image(.importExport16)) then **Export bookmarks to HTML…**" - 4; "Save the file someplace you can find it (e.g., Desktop)" - 5; .button("Select Bookmarks HTML File…") - - case (.onePassword8, .passwords): - 1; "Open and unlock **\(source.importSourceName)**" - 2; "Select **File → Export** from the Menu Bar and choose the account you want to export" - 3; "Enter your 1Password account password" - 4; "Select the File Format: **CSV (Logins and Passwords only)**" - 5; "Click Export Data and save the file someplace you can find it (e.g. Desktop)" - 6; .button("Select 1Password CSV File…") - case (.onePassword7, .passwords): - 1; "Open and unlock **\(source.importSourceName)**" - 2; "Select the vault you want to Export (You cannot export from “All Vaults.”)" - 3; "Select **File → Export → All Items** from the Menu Bar" - 4; "Enter your 1Password master or account password" - 5; "Select the File Format: **iCloud Keychain (.csv)**" - 6; "Save the passwords file someplace you can find it (e.g. Desktop)" - 7; .button("Select 1Password CSV File…") - case (.bitwarden, .passwords): - 1; "Open and unlock **\(source.importSourceName)**" - 2; "Select **File → Export vault** from the Menu Bar" - 3; "Select the File Format: **.csv**" - 4; "Enter your Bitwarden Master password" - 5; "Click \(Image(systemName: "square.and.arrow.down")) and save the file someplace you can find it (e.g. Desktop)" - 6; .button("Select Bitwarden CSV File…") - - case (.lastPass, .passwords): - 1; "Click on the **\(source.importSourceName)** icon in your browser and enter your master password" - 2; "Select **Open My Vault**" - 3; "From the sidebar select **Advanced Options → Export**" - 4; "Enter your LastPass master password" - 5; "Select the File Format: Comma Delimited Text (.csv)" - 6; .button("Select LastPass CSV File…") - case (.csv, .passwords): - """ - The CSV importer will try to match column headers to their position. - If there is no header, it supports two formats: - """ - 1; "URL, Username, Password" - 2; "Title, URL, Username, Password"; - - .button("Select Passwords CSV File…") - - case (.bookmarksHTML, .bookmarks): - 1; "Open your old browser" - 2; "Click \(Image(.menuHamburger16)) then select **Bookmarks → Bookmark Manager**" - 3; "Click \(Image(.menuVertical16)) then **Export bookmarks to HTML…**" - 4; "Save the file someplace you can find it (e.g., Desktop)" - 5; .button("Select Bookmarks HTML File…") - - case (.bookmarksHTML, .passwords), - (.tor, .passwords), - (.onePassword7, .bookmarks), - (.onePassword8, .bookmarks), - (.bitwarden, .bookmarks), - (.lastPass, .bookmarks), - (.csv, .bookmarks): - { - assertionFailure("Invalid source/dataType") - return "" - }() - } - } - } - var body: some View { VStack(alignment: .leading, spacing: 10) { { @@ -185,25 +48,280 @@ struct FileImportView: View { } }().font(.headline) - ForEach(instructions.indices, id: \.self) { i in - HStack(spacing: 4) { - ForEach(instructions[i].indices, id: \.self) { j in - switch instructions[i][j] { - case .number(let number): - CircleNumberView(number: number) - case .text(let localizedStringKey): - Text(localizedStringKey) - case .button(let localizedTitleKey): - Button(localizedTitleKey, action: action) - .onDrop(of: dataType.allowedFileTypes, isTargeted: nil, perform: onDrop) - .disabled(isButtonDisabled) - } - } + InstructionsView(fontName: "SF Pro Text", fontSize: 13) { + + switch (source, dataType) { + case (.chrome, .passwords): + NSLocalizedString("import.csv.instructions.chrome", value: """ + %d Open **%s** + %d In a fresh tab, click %@ then **Google Password Manager → Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Google Chrome browser. + %d is a step number; %s is a Browser name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button("Select Passwords CSV File…") + + case (.brave, .passwords), + (.chromium, .passwords), + (.coccoc, .passwords), + (.edge, .passwords), + (.vivaldi, .passwords), + (.opera, .passwords), + (.operaGX, .passwords): + + NSLocalizedString("import.csv.instructions.chromium", value: """ + %d Open **%s** + %d In a fresh tab, click %@ then **Password Manager → Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %d is a step number; %s is a Browser name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button("Select Passwords CSV File…") + + case (.yandex, .passwords): + NSLocalizedString("import.csv.instructions.yandex", value: """ + %d Open **Yandex** + %d Click %@ to open the application menu then click **Passwords and cards** + %d Click %@ then **Export passwords** + %d Choose **To a text file (not secure)** and click **Export** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Yandex Browser. + %d is a step number; %@ is for a button image to click + **bold text**; _italic text_ + """) + NSImage.menuHamburger16 + NSImage.menuVertical16 + button("Select Passwords CSV File…") + + case (.brave, .bookmarks), + (.chrome, .bookmarks), + (.chromium, .bookmarks), + (.coccoc, .bookmarks), + (.edge, .bookmarks), + (.vivaldi, .bookmarks), + (.opera, .bookmarks), + (.operaGX, .bookmarks): + NSLocalizedString("import.html.instructions.chromium", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmark Manager** + %d Click %@ then **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Chromium-based browsers. + %d is a step number; %s is a Browser name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button("Select Bookmarks HTML File…") + + case (.yandex, .bookmarks): + NSLocalizedString("import.html.instructions.yandex", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Favorites → Bookmark Manager** + %d Click %@ then **Export bookmarks to HTML file** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Yandex Browser. + %d is a step number; %s is a Browser name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button("Select Bookmarks HTML File…") + + case (.safari, .passwords), (.safariTechnologyPreview, .passwords): + NSLocalizedString("import.csv.instructions.safari", value: """ + %d Open **Safari** + %d Select **File → Export → Passwords** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Safari. + %d is a step number; %@ is for a button image to click + **bold text**; _italic text_ + """) + + button("Select Passwords CSV File…") + + case (.safari, .bookmarks), (.safariTechnologyPreview, .bookmarks): + NSLocalizedString("import.html.instructions.safari", value: """ + %d Open **Safari** + %d Select **File → Export → Bookmarks** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Safari. + %d is a step number; %@ is for a button image to click + **bold text**; _italic text_ + """) + button("Select Bookmarks HTML File…") + + case (.firefox, .passwords): + NSLocalizedString("import.csv.instructions.firefox", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Passwords** + %d Click %@ then **Export Logins…** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Firefox. + %d is a step number; %s is a Browser name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuVertical16 + button("Select Passwords CSV File…") + + case (.firefox, .bookmarks), (.tor, .bookmarks): + NSLocalizedString("import.html.instructions.firefox", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Manage Bookmarks** + %d Click %@ then **Export bookmarks to HTML…** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Firefox based browsers. + %d is a step number; %s is a Browser name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.importExport16 + button("Select Bookmarks HTML File…") + + case (.onePassword8, .passwords): + NSLocalizedString("import.csv.instructions.onePassword8", value: """ + %d Open and unlock **%s** + %d Select **File → Export** from the Menu Bar and choose the account you want to export + %d Enter your 1Password account password + %d Select the File Format: **CSV (Logins and Passwords only)** + %d Click Export Data and save the file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from 1Password 8. + %d is a step number; %s is 1Password app name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + button("Select 1Password CSV File…") + + case (.onePassword7, .passwords): + NSLocalizedString("import.csv.instructions.onePassword7", value: """ + %d Open and unlock **%s** + %d Select the vault you want to Export (You cannot export from “All Vaults.”) + %d Select **File → Export → All Items** from the Menu Bar + %d Enter your 1Password master or account password + %d Select the File Format: **iCloud Keychain (.csv)** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from 1Password 7. + %d is a step number; %s is 1Password app name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + button("Select 1Password CSV File…") + + case (.bitwarden, .passwords): + NSLocalizedString("import.csv.instructions.bitwarden", value: """ + %d Open and unlock **%s** + %d Select **File → Export vault** from the Menu Bar + %d Select the File Format: **.csv** + %d Enter your Bitwarden Master password + %d Click %@ and save the file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Bitwarden. + %d is a step number; %s is Bitwarden app name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + NSImage(systemSymbolName: "square.and.arrow.down", accessibilityDescription: nil) ?? .downloads + button("Select Bitwarden CSV File…") + + case (.lastPass, .passwords): + NSLocalizedString("import.csv.instructions.lastpass", value: """ + %d Click on the **%s** icon in your browser and enter your master password + %d Select **Open My Vault** + %d From the sidebar select **Advanced Options → Export** + %d Enter your LastPass master password + %d Select the File Format: **Comma Delimited Text (.csv)** + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from LastPass. + %d is a step number; %s is LastPass app name; %@ is for a button image to click + **bold text**; _italic text_ + """) + source.importSourceName + button("Select LastPass CSV File…") + + case (.csv, .passwords): + NSLocalizedString("import.csv.instructions.generic", value: """ + The CSV importer will try to match column headers to their position. + If there is no header, it supports two formats: + %d URL, Username, Password + %d Title, URL, Username, Password + %@ + """, comment: """ + Instructions to import a generic CSV passwords file. + %d is a step number; %@ is for a button image to click + **bold text**; _italic text_ + """) + + button("Select Passwords CSV File…") + + case (.bookmarksHTML, .bookmarks): + NSLocalizedString("import.html.instructions.generic", value: """ + %d Open your old browser + %d Click %@ then select **Bookmarks → Bookmark Manager** + %d Click %@ then **Export bookmarks to HTML…** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import a generic HTML Bookmarks file. + %d is a step number; %@ is for a button image to click + **bold text**; _italic text_ + """) + NSImage.menuHamburger16 + NSImage.menuVertical16 + button("Select Bookmarks HTML File…") + + case (.bookmarksHTML, .passwords), + (.tor, .passwords), + (.onePassword7, .bookmarks), + (.onePassword8, .bookmarks), + (.bitwarden, .bookmarks), + (.lastPass, .bookmarks), + (.csv, .bookmarks): + assertionFailure("Invalid source/dataType") } } } } + private func button(_ localizedTitleKey: LocalizedStringKey) -> some View { + Button(localizedTitleKey, action: action) + .onDrop(of: dataType.allowedFileTypes, isTargeted: nil, perform: onDrop) + .disabled(isButtonDisabled) + } + private func onDrop(_ providers: [NSItemProvider], _ location: CGPoint) -> Bool { let allowedTypeIdentifiers = providers.reduce(into: Set()) { $0.formUnion($1.registeredTypeIdentifiers) @@ -245,107 +363,237 @@ struct FileImportView: View { } -struct CircleNumberView: View { +struct InstructionsView: View { - let number: Int - - var body: some View { - Circle() - .fill(.globalBackground) - .frame(width: 20, height: 20) - .overlay( - Text("\(number)") - .foregroundColor(.onboardingActionButton) - .font(.headline) - ) + enum TextPart { + case image(NSImage) + case text(text: String, isBold: Bool, isItalic: Bool) + } + enum InstructionsViewItem { + case lineNumber(Int) + case textParts([TextPart]) + case view(AnyView) } -} + let fontName: String + let fontSize: CGFloat -// MARK: - Preview + private let instructions: [[InstructionsViewItem]] -#Preview { - FileImportView(source: .bitwarden, dataType: .passwords, isButtonDisabled: false) - .frame(width: 512 - 20) + // swiftlint:disable:next function_body_length cyclomatic_complexity + init(fontName: String, fontSize: CGFloat, @InstructionsBuilder builder: () -> [InstructionsItem]) { + self.fontName = fontName + self.fontSize = fontSize -} + let items = builder() -// MARK: - instructions builder helper + guard case .string(let format) = items.first else { + assertionFailure("First item should provide instructions format using NSLocalizedString") + self.instructions = [] + return + } -private enum FileImportInstructionsItem { - case number(Int) - case text(LocalizedStringKey) - case button(LocalizedStringKey) -} + do { -@resultBuilder -private struct FileImportInstructionsBuilder { - static func buildBlock(_ components: [FileImportInstructionsItem]...) -> [FileImportInstructionsItem] { - return components.flatMap { $0 } - } + let formatLines = try InstructionsFormatParser().parse(format: format) - static func buildOptional(_ components: [FileImportInstructionsItem]?) -> [FileImportInstructionsItem] { - return components ?? [] - } + var result = [[InstructionsViewItem]]() + var argIndex = 1 + var lineNumber = 1 - static func buildEither(first component: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { - component - } + func fline(_ lineIdx: Int) -> String { + format.components(separatedBy: "\n")[safe: lineIdx] ?? "?" + } - static func buildEither(second component: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { - component - } + for (lineIdx, line) in formatLines.enumerated() { + var resultLine = [InstructionsViewItem]() + func appendTextPart(_ textPart: TextPart) { + if case .textParts(var parts) = resultLine.last { + parts.append(textPart) + resultLine[resultLine.endIndex - 1] = .textParts(parts) + } else { + resultLine.append(.textParts([textPart])) + } + } - static func buildLimitedAvailability(_ component: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { - component - } + for component in line { + switch component { + case .number: + resultLine.append(.lineNumber(lineNumber)) + lineNumber += 1 + case .text(let text, bold: let bold, italic: let italic): + appendTextPart(.text(text: text, isBold: bold, isItalic: italic)) + case .string(bold: let bold, italic: let italic): + switch items[safe: argIndex] { + case .string(let str): + appendTextPart(.text(text: str, isBold: bold, isItalic: italic)) + case .none: + assertionFailure("String argument missing at index \(argIndex) in “\(fline(lineIdx))”") + case .image(let obj as Any), .view(let obj as Any): + assertionFailure("Unexpected object argument “\(obj)”, expected string at index \(argIndex) in “\(fline(lineIdx))”") + } + argIndex += 1 + + case .object: + switch items[safe: argIndex] { + case .image(let image): + appendTextPart(.image(image)) + case .view(let view): + resultLine.append(.view(view)) + case .none: + assertionFailure("Object argument missing at index \(argIndex) in “\(fline(lineIdx))”") + case .string(let string): + assertionFailure("Unexpected string argument “\(string)”, expected object at index \(argIndex) in “\(fline(lineIdx))”") + } - static func buildArray(_ components: [[FileImportInstructionsItem]]) -> [FileImportInstructionsItem] { - components.flatMap { $0 } - } + argIndex += 1 + } + } + result.append(resultLine) + } + if argIndex < items.count { + assertionFailure("Argument \(items[argIndex]) not used anywhere") + } - static func buildExpression(_ expression: [FileImportInstructionsItem]) -> [FileImportInstructionsItem] { - return expression - } + self.instructions = result - static func buildExpression(_ value: Int) -> [FileImportInstructionsItem] { - return [.number(value)] + } catch { + assertionFailure("Could not build instructions view: \(error)") + self.instructions = [] + } } - static func buildExpression(_ value: LocalizedStringKey) -> [FileImportInstructionsItem] { - return [.text(value)] + enum InstructionsItem { + case string(String) + case image(NSImage) + case view(AnyView) } - static func buildExpression(_ value: FileImportInstructionsItem) -> [FileImportInstructionsItem] { - return [value] + @resultBuilder + struct InstructionsBuilder { + static func buildBlock(_ components: [InstructionsItem]...) -> [InstructionsItem] { + return components.flatMap { $0 } + } + + static func buildOptional(_ components: [InstructionsItem]?) -> [InstructionsItem] { + return components ?? [] + } + + static func buildEither(first component: [InstructionsItem]) -> [InstructionsItem] { + component + } + + static func buildEither(second component: [InstructionsItem]) -> [InstructionsItem] { + component + } + + static func buildLimitedAvailability(_ component: [InstructionsItem]) -> [InstructionsItem] { + component + } + + static func buildArray(_ components: [[InstructionsItem]]) -> [InstructionsItem] { + components.flatMap { $0 } + } + + static func buildExpression(_ expression: [InstructionsItem]) -> [InstructionsItem] { + return expression + } + + static func buildExpression(_ value: String) -> [InstructionsItem] { + return [.string(value)] + } + + static func buildExpression(_ value: NSImage) -> [InstructionsItem] { + return [.image(value)] + } + + static func buildExpression(_ value: some View) -> [InstructionsItem] { + return [.view(AnyView(value))] + } + + static func buildExpression(_ expression: Void) -> [InstructionsItem] { + return [] + } + } - static func buildExpression(_ expression: Void) -> [FileImportInstructionsItem] { - return [] + var body: some View { + ForEach(instructions.indices, id: \.self) { i in + HStack(spacing: 4) { + ForEach(instructions[i].indices, id: \.self) { j in + switch instructions[i][j] { + case .lineNumber(let number): + CircleNumberView(number: number) + case .textParts(let textParts): + Text(textParts, fontName: fontName, fontSize: fontSize) + case .view(let view): + view + } + } + } + } } } -private func buildInstructions(@FileImportInstructionsBuilder builder: () -> [FileImportInstructionsItem]) -> [[FileImportInstructionsItem]] { - let items = builder() - - // zip [1, "text 1", 2, "text 2", "text 3"] to [[1, "text 1"], [2, "text 2"], ["text 3"]] - var result: [[FileImportInstructionsItem]] = [] - var currentNumber: Int? - - for item in items { - switch item { - case .number(let num): - currentNumber = num - case .text, .button: - if let currentNumber { - result.append([.number(currentNumber), item]) - } else { - result.append([item]) +private extension Text { + + init(_ textPart: InstructionsView.TextPart, fontName: String, fontSize: CGFloat) { + switch textPart { + case .image(let image): + self.init(Image(nsImage: image)) + self = self.baselineOffset(fontSize - image.size.height) + case .text(let text, let isBold, let isItalic): + self.init(text) + self = self.font(.custom(fontName, size: fontSize)) + if isBold { + self = self.bold() } - currentNumber = nil + if isItalic { + self = self.italic() + } + } + } + + init(_ textParts: [InstructionsView.TextPart], fontName: String, fontSize: CGFloat) { + guard !textParts.isEmpty else { + assertionFailure("Empty TextParts") + self.init("") + return + } + self.init(textParts[0], fontName: fontName, fontSize: fontSize) + + guard textParts.count > 1 else { return } + for textPart in textParts[1...] { + // swiftlint:disable:next shorthand_operator + self = self + Text(textPart, fontName: fontName, fontSize: fontSize) } } - return result +} + +struct CircleNumberView: View { + + let number: Int + + var body: some View { + Circle() + .fill(.globalBackground) + .frame(width: 20, height: 20) + .overlay( + Text("\(number)") + .foregroundColor(.onboardingActionButton) + .font(.headline) + + ) + } + +} + +// MARK: - Preview + +#Preview { + FileImportView(source: .bitwarden, dataType: .passwords, isButtonDisabled: false) + .frame(width: 512 - 20) + } diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 8a470103bc..695d0864f7 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -1,5 +1,8481 @@ { "sourceLanguage" : "en", - "strings" : {}, + "strings" : { + "" : { + + }, + "**%lld** tracking attempts blocked" : { + + }, + "%@ does not support storing %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ does not support storing %2$@" + } + } + } + }, + "%@ Import Complete" : { + + }, + "%lld" : { + + }, + "%lld tracking attempts blocked" : { + + }, + "••••••••••••" : { + + }, + "`continue`" : { + "comment" : "Continue button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Continue" + } + } + } + }, + "⚠️ Notes are deprecated." : { + + }, + "about.app_name" : { + "comment" : "Application name to be displayed in the About dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo" + } + } + } + }, + "about.app_name_app_store" : { + "comment" : "Application name to be displayed in the About dialog in App Store app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo for Mac App Store" + } + } + } + }, + "Actual Size" : { + "comment" : "Main Menu View item" + }, + "Add any details that you think may help us fix the problem" : { + + }, + "add.favorite" : { + "comment" : "Button for adding a favorite bookmark", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Favorite" + } + } + } + }, + "add.link.to.bookmarks" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Link to Bookmarks" + } + } + } + }, + "add.to.favorites" : { + "comment" : "Button for adding bookmarks to favorites", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to Favorites" + } + } + } + }, + "Address" : { + + }, + "address.bar.search.suffix" : { + "comment" : "Suffix of searched terms in address bar. Example: best watching machine . Search DuckDuckGo", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search DuckDuckGo" + } + } + } + }, + "address.bar.visit.suffix" : { + "comment" : "Address bar suffix of possibly visited website. Example: spreadprivacy.com . Visit spreadprivacy.com", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Visit" + } + } + } + }, + "after.bitwarden.installation.info" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "After installing, return to DuckDuckGo to complete the setup." + } + } + } + }, + "alert.sync-bookmarks-paused-description" : { + "comment" : "Description for alert shown when sync bookmarks paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You have exceeded the bookmarks sync limit. Try deleting some bookmarks. Until this is resolved your bookmarks will not be backed up." + } + } + } + }, + "alert.sync-bookmarks-paused-title" : { + "comment" : "Title for alert shown when sync bookmarks paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks Sync is Paused" + } + } + } + }, + "alert.sync-credentials-paused-description" : { + "comment" : "Description for alert shown when sync credentials paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You have exceeded the passwords sync limit. Try deleting some passwords. Until this is resolved your passwords will not be backed up." + } + } + } + }, + "alert.sync-credentials-paused-title" : { + "comment" : "Title for alert shown when sync credentials paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords Sync is Paused" + } + } + } + }, + "alert.title" : { + "comment" : "Title formatted with presenting domain", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A message from %@" + } + } + } + }, + "allow.integration" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow Integration with DuckDuckGo" + } + } + } + }, + "auth.alert.login.button" : { + "comment" : "Authentication Alert Sign In Button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign In" + } + } + } + }, + "auth.alert.message.encrypted" : { + "comment" : "Authentication Alert - populated with a domain name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to %@. Your login information will be sent securely." + } + } + } + }, + "auth.alert.message.plain" : { + "comment" : "Authentication Alert - populated with a domain name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log in to %@. Your password will be sent insecurely because the connection is unencrypted." + } + } + } + }, + "auth.alert.password.placeholder" : { + "comment" : "Authentication Password field placeholder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password" + } + } + } + }, + "auth.alert.title" : { + "comment" : "Authentication Alert Title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Authentication Required" + } + } + } + }, + "auth.alert.username.placeholder" : { + "comment" : "Authentication User name field placeholder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Username" + } + } + } + }, + "autoconsent.checkbox.title" : { + "comment" : "Autoconsent settings checkbox title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically handle cookie pop-ups" + } + } + } + }, + "autoconsent.explanation" : { + "comment" : "Autoconsent feature explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo will try to select the most private settings available and hide these pop-ups for you." + } + } + } + }, + "autoconsent.from.setup.modal.body" : { + "comment" : "Body for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "When we detect cookie pop-ups on sites you visit, we can try to select the most private settings available and hide pop-ups like this." + } + } + } + }, + "autoconsent.from.setup.modal.cta.confirm" : { + "comment" : "Confirm button for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Handle Pop-ups For Me" + } + } + } + }, + "autoconsent.from.setup.modal.title" : { + "comment" : "Title for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want DuckDuckGo to handle cookie pop-ups?" + } + } + } + }, + "autoconsent.modal.body" : { + "comment" : "Body for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want me to handle these for you? I can try to minimize cookies, maximize privacy, and hide pop-ups like these." + } + } + } + }, + "autoconsent.modal.cta.confirm" : { + "comment" : "Confirm button for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Cookie Pop-ups" + } + } + } + }, + "autoconsent.modal.cta.deny" : { + "comment" : "Deny button for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Thanks" + } + } + } + }, + "autoconsent.modal.title" : { + "comment" : "Title for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Looks like this site has a cookie pop-up 👇" + } + } + } + }, + "autoconsent.title" : { + "comment" : "Autoconsent settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookie Pop-ups" + } + } + } + }, + "autofill.addresses" : { + "comment" : "Autofill autosaved data type", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Addresses" + } + } + } + }, + "autofill.ask-to-save" : { + "comment" : "Autofill settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save and Autofill" + } + } + } + }, + "autofill.ask-to-save.explanation" : { + "comment" : "Description of Autofill autosaving feature - used in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Receive prompts to save new information and autofill online forms." + } + } + } + }, + "autofill.auto-lock" : { + "comment" : "Autofill settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Auto-lock" + } + } + } + }, + "autofill.autolock-locks-form-filling" : { + "comment" : "Lock form filling when auto-lock is active text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Also lock access to Login and Credit Card form fill." + } + } + } + }, + "autofill.copy-password" : { + "comment" : "Tooltip for the Autofill panel's Copy Password button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy password" + } + } + } + }, + "autofill.copy-username" : { + "comment" : "Tooltip for the Autofill panel's Copy Username button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy username" + } + } + } + }, + "autofill.hide-password" : { + "comment" : "Tooltip for the Autofill panel's Hide Password button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide password" + } + } + } + }, + "autofill.lock-when-idle" : { + "comment" : "Autofill auto-lock setting", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Lock Autofill after computer is idle for" + } + } + } + }, + "autofill.manager.status.locked" : { + "comment" : "Locked status for password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Locked" + } + } + } + }, + "autofill.manager.status.unlocked" : { + "comment" : "Unlocked status for password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unlocked" + } + } + } + }, + "autofill.never-lock" : { + "comment" : "Autofill auto-lock setting", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never lock Autofill" + } + } + } + }, + "autofill.never-lock-warning" : { + "comment" : "Autofill disabled auto-lock warning", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Anyone with access to your device will be able to use and modify your Autofill data if not locked." + } + } + } + }, + "autofill.password-manager" : { + "comment" : "Autofill settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password Manager" + } + } + } + }, + "autofill.password-manager.bitwarden" : { + "comment" : "Autofill password manager row title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden" + } + } + } + }, + "autofill.password-manager.bitwarden.disclaimer" : { + "comment" : "Autofill password manager Bitwarden disclaimer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Setup requires installing the Bitwarden app." + } + } + } + }, + "autofill.password-manager.duckduckgo" : { + "comment" : "Autofill password manager row title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo built-in password manager" + } + } + } + }, + "autofill.payment-methods" : { + "comment" : "Autofill autosaved data type", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Payment methods" + } + } + } + }, + "autofill.popover.autosave.button.text" : { + "comment" : "Button to view the recently autosaved password", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View" + } + } + } + }, + "autofill.popover.autosave.text" : { + "comment" : "Text confirming a password has been saved for the %@ domain", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Login saved for %@" + } + } + } + }, + "autofill.popover.change-in" : { + "comment" : "Suffix of the label - change in settings - ", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change in" + } + } + } + }, + "autofill.popover.open-password-manager" : { + "comment" : "Open password manager button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open %@" + } + } + } + }, + "autofill.popover.password-manager-connected-to-user" : { + "comment" : "Label describing what user is connected to the password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connected to user %@" + } + } + } + }, + "autofill.popover.password-manager-title" : { + "comment" : "Explanation of what password manager is being used", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You're using %@ to manage passwords" + } + } + } + }, + "autofill.popover.settings-button" : { + "comment" : "Open Settings Button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "autofill.show-password" : { + "comment" : "Tooltip for the Autofill panel's Show Password button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show password" + } + } + } + }, + "autofill.usernames-and-passwords" : { + "comment" : "Autofill autosaved data type", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Usernames and passwords" + } + } + } + }, + "autofill.view-autofill-content" : { + "comment" : "View Autofill Content Button name in the autofill settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View Autofill Content…" + } + } + } + }, + "Birthday" : { + + }, + "bitwarden.app.found" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden app found!" + } + } + } + }, + "bitwarden.cant.access.container" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs permission to access Bitwarden. You can grant DuckDuckGo Full Disk Access in System Settings, or switch back to the built-in password manager." + } + } + } + }, + "bitwarden.connect.communication-info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All communication between Bitwarden and DuckDuckGo is encrypted and the data never leaves your device." + } + } + } + }, + "bitwarden.connect.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll walk you through connecting to Bitwarden, so you can use it in DuckDuckGo." + } + } + } + }, + "bitwarden.connect.history-info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden will have access to your browsing history." + } + } + } + }, + "bitwarden.connect.privacy" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy" + } + } + } + }, + "bitwarden.connect.title" : { + "comment" : "Title for the Bitwarden onboarding flow", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connect to Bitwarden" + } + } + } + }, + "bitwarden.connecting" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connecting to Bitwarden" + } + } + } + }, + "bitwarden.error" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unable to find or connect to Bitwarden" + } + } + } + }, + "bitwarden.handshake.not.approved" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Handshake not approved in Bitwarden app" + } + } + } + }, + "bitwarden.install" : { + "comment" : "Button to install Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Install Bitwarden" + } + } + } + }, + "bitwarden.install.info" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "To begin setup, first install Bitwarden from the App Store." + } + } + } + }, + "bitwarden.integration.complete" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden integration complete!" + } + } + } + }, + "bitwarden.integration.complete.info" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You are now using Bitwarden as your password manager." + } + } + } + }, + "bitwarden.integration.not.approved" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Integration with DuckDuckGo is not approved in Bitwarden app" + } + } + } + }, + "bitwarden.is.ready.to.connect" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden is ready to connect to DuckDuckGo!" + } + } + } + }, + "bitwarden.missing.handshake" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Missing handshake" + } + } + } + }, + "bitwarden.not.installed" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden app is not installed" + } + } + } + }, + "bitwarden.old.version" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please update Bitwarden to the latest version" + } + } + } + }, + "bitwarden.preferences.complete-setup" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Complete Setup…" + } + } + } + }, + "bitwarden.preferences.open-bitwarden" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bitwarden" + } + } + } + }, + "bitwarden.preferences.run" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden app not running" + } + } + } + }, + "bitwarden.preferences.unable-to-connect" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unable to find or connect to Bitwarden" + } + } + } + }, + "bitwarden.preferences.unlock" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unlock Bitwarden" + } + } + } + }, + "bitwarden.waiting.for.handshake" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for the handshake approval in Bitwarden app" + } + } + } + }, + "bitwarden.waiting.for.permissions" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for permission to use Bitwarden in DuckDuckGo…" + } + } + } + }, + "bitwarden.waiting.for.status.response" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for the status response from Bitwarden" + } + } + } + }, + "Bookmark import failed: %lld" : { + + }, + "bookmark.page" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark Page" + } + } + } + }, + "bookmark.this.page" : { + "comment" : "Menu item for bookmarking current page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark This Page" + } + } + } + }, + "bookmark.update" : { + "comment" : "Option for updating a bookmark", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Update Bookmark" + } + } + } + }, + "bookmarks" : { + "comment" : "Button for bookmarks", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "Bookmarks import should fail" : { + + }, + "Bookmarks: %lld" : { + + }, + "Bookmarks…" : { + "comment" : "Main Menu File-Export item" + }, + "bookmarks.bar.context-menu.copy" : { + "comment" : "Copy menu item for the bookmarks bar context menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy" + } + } + } + }, + "bookmarks.bar.context-menu.delete" : { + "comment" : "Delete menu item for the bookmarks bar context menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete" + } + } + } + }, + "bookmarks.bar.context-menu.move-to-end" : { + "comment" : "Move to End menu item for the bookmarks bar context menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Move to End" + } + } + } + }, + "bookmarks.bar.folder.empty" : { + "comment" : "Empty state for a bookmarks bar folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Empty" + } + } + } + }, + "bookmarks.bar.prompt.accept" : { + "comment" : "Accept button label on bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show" + } + } + } + }, + "bookmarks.bar.prompt.dismiss" : { + "comment" : "Dismiss button label on bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide" + } + } + } + }, + "bookmarks.bar.prompt.message" : { + "comment" : "Message show for bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the Bookmarks Bar for quick access to your new bookmarks." + } + } + } + }, + "bookmarks.bar.prompt.title" : { + "comment" : "Title for bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Bookmarks Bar?" + } + } + } + }, + "bookmarks.bar.show" : { + "comment" : "Menu item for showing the bookmarks bar\n Preference item for showing the bookmarks bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks Bar" + } + } + } + }, + "bookmarks.bar.show.always" : { + "comment" : "Preference for always showing the bookmarks bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always Show" + } + } + } + }, + "bookmarks.bar.show.never" : { + "comment" : "Preference for never showing the bookmarks bar on new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never Show" + } + } + } + }, + "bookmarks.bar.show.new-tab-only" : { + "comment" : "Preference for only showing the bookmarks bar on new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Only Show on New Tab" + } + } + } + }, + "bookmarks.imported.from.folder" : { + "comment" : "Name of the folder the imported bookmarks are saved into", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Imported from" + } + } + } + }, + "bookmarks.manage" : { + "comment" : "Button for opening the bookmarks management interface", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage" + } + } + } + }, + "bookmarks.manage-bookmarks" : { + "comment" : "Menu item for opening the bookmarks management interface", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Bookmarks" + } + } + } + }, + "bookmarks.open.in.new.tabs" : { + "comment" : "Open all bookmarks in folder in new tabs", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in New Tabs" + } + } + } + }, + "bookmarks.show-toolbar-panel" : { + "comment" : "Menu item for opening the bookmarks panel", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bookmarks Panel" + } + } + } + }, + "Bring All to Front" : { + "comment" : "Main Menu Window item" + }, + "burner.homepage.description.1" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Browse without saving local history" + } + } + } + }, + "burner.homepage.description.2" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to a site with a different account" + } + } + } + }, + "burner.homepage.description.3" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Troubleshoot websites" + } + } + } + }, + "burner.homepage.description.4" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fire windows are isolated from other browser data, and their data is burned when you close them. They have the same tracking protection as other windows." + } + } + } + }, + "burner.tab.home.title" : { + "comment" : "Tab title for Fire Tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Fire Tab" + } + } + } + }, + "burner.window.header" : { + "comment" : "Header shown on the hompage of the Fire Window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fire Window" + } + } + } + }, + "cancel" : { + "comment" : "Cancel button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "cannot.open.file.alert.header" : { + "comment" : "Header of the alert dialog informing user it is not possible to open the file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cannot Open File" + } + } + } + }, + "cannot.open.file.alert.informative" : { + "comment" : "Informative of the alert dialog informing user it is not possible to open the file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The App Store version of DuckDuckGo can only access local files if you drag-and-drop them into a browser window.\n\n To navigate local files using the address bar, please download DuckDuckGo directly from https://duckduckgo.com/mac." + } + } + } + }, + "Capitalize" : { + "comment" : "Main Menu Edit-Transformations item" + }, + "Check Document Now" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Check for Updates…" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Check Grammar With Spelling" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Check Spelling While Typing" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "check.allow.integration" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check Allow integration with DuckDuckGo." + } + } + } + }, + "check.for.update" : { + "comment" : "Button users can use to check for a new update", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check for Update" + } + } + } + }, + "clear" : { + "comment" : "Clear button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear" + } + } + } + }, + "Clear All History…" : { + "comment" : "Main Menu History item" + }, + "Close All Windows" : { + "comment" : "Main Menu File item" + }, + "Close Window" : { + "comment" : "Main Menu File item" + }, + "close.other.tabs" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Other Tabs" + } + } + } + }, + "close.tab" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Tab" + } + } + } + }, + "close.tab.on.back" : { + "comment" : "Close Child Tab on Back Button press and return Back to the Parent Tab without title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close and Return to Previous Tab" + } + } + } + }, + "close.tab.on.back.format" : { + "comment" : "Close Child Tab on Back Button press and return Back to the Parent Tab titled “%@”", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close and Return to “%@”" + } + } + } + }, + "close.tabs.to.the.right" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Tabs to the Right" + } + } + } + }, + "Contact Info" : { + + }, + "copy" : { + "comment" : "Copy button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy" + } + } + } + }, + "Copy" : { + "comment" : "Command\nMain Menu Edit item" + }, + "copy-selection" : { + "comment" : "Copy selection menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy" + } + } + } + }, + "copy.image.address" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy Image Address" + } + } + } + }, + "Correct Spelling Automatically" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Country" : { + + }, + "Cut" : { + "comment" : "Main Menu Edit item" + }, + "dashboard.permission.allow" : { + "comment" : "Privacy Dashboard: Website can always access input media device", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always allow" + } + } + } + }, + "dashboard.permission.allow.on" : { + "comment" : "Permission Popover 'Always allow on' (for domainName) checkbox", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always allow on" + } + } + } + }, + "dashboard.permission.ask" : { + "comment" : "Privacy Dashboard: Website should always Ask for permission for input media device access", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ask every time" + } + } + } + }, + "dashboard.permission.deny" : { + "comment" : "Privacy Dashboard: Website can never access input media device", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always deny" + } + } + } + }, + "dashboard.popups.ask" : { + "comment" : "Make PopUp Windows always asked from user for current domain", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notify" + } + } + } + }, + "Data Detectors" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "data-broker-protection.optionsMenu" : { + "comment" : "Menu item data broker protection feature", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal" + } + } + } + }, + "data-broker-protection.privacy-policy.title" : { + "comment" : "Privacy Policy title for Personal Information Removal", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Policy" + } + } + } + }, + "data-broker-protection.waitlist.availability-disclaimer" : { + "comment" : "Availability disclaimer for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal is free to use during the beta." + } + } + } + }, + "data-broker-protection.waitlist.button.agree-and-continue" : { + "comment" : "Agree and Continue button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Agree and Continue" + } + } + } + }, + "data-broker-protection.waitlist.button.cancel" : { + "comment" : "Cancel button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "data-broker-protection.waitlist.button.close" : { + "comment" : "Close button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close" + } + } + } + }, + "data-broker-protection.waitlist.button.dismiss" : { + "comment" : "Dismiss button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dismiss" + } + } + } + }, + "data-broker-protection.waitlist.button.done" : { + "comment" : "Close button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "data-broker-protection.waitlist.button.enable-notifications" : { + "comment" : "Enable Notifications button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Notifications" + } + } + } + }, + "data-broker-protection.waitlist.button.get-started" : { + "comment" : "Get Started button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "data-broker-protection.waitlist.button.got-it" : { + "comment" : "Get started button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get started" + } + } + } + }, + "data-broker-protection.waitlist.button.join-waitlist" : { + "comment" : "Join Waitlist button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join the Waitlist" + } + } + } + }, + "data-broker-protection.waitlist.button.no-thanks" : { + "comment" : "No Thanks button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Thanks" + } + } + } + }, + "data-broker-protection.waitlist.enable-notifications" : { + "comment" : "Enable notifications prompt for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want to get a notification when your Personal Information Removal invite is ready?" + } + } + } + }, + "data-broker-protection.waitlist.enable.subtitle" : { + "comment" : "Subtitle for Personal Information Removal enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll need your name, address and the year you were born in order to find your personal information on data broker sites\n\nThis info is stored securely on your device, and is never sent to DuckDuckGo." + } + } + } + }, + "data-broker-protection.waitlist.enable.title" : { + "comment" : "Title for Personal Information Removal enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Let’s get started" + } + } + } + }, + "data-broker-protection.waitlist.invited.section-1.subtitle" : { + "comment" : "Subtitle for section 1 of the Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically scans for your info, requests its removal, and re-scans regularly to ensure it doesn’t reappear." + } + } + } + }, + "data-broker-protection.waitlist.invited.section-1.title" : { + "comment" : "Title for section 1 of the Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Continuous Scan and Removal" + } + } + } + }, + "data-broker-protection.waitlist.invited.section-2.subtitle" : { + "comment" : "Subtitle for section 2 of the Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The removal process is initiated on your device, and the info you provide during setup is stored on your device only." + } + } + } + }, + "data-broker-protection.waitlist.invited.section-2.title" : { + "comment" : "Title for section 2 of the Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Private by Design" + } + } + } + }, + "data-broker-protection.waitlist.invited.section-3.subtitle" : { + "comment" : "Subtitle for section 3 of the Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "See what information has been removed, and monitor progress of ongoing removals from your dashboard." + } + } + } + }, + "data-broker-protection.waitlist.invited.section-3.title" : { + "comment" : "Title for section 3 of the Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Real-Time Progress Updates" + } + } + } + }, + "data-broker-protection.waitlist.invited.subtitle" : { + "comment" : "Subtitle for Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically find and remove your personal information – such as your name and address – from 17+ sites that store and sell it, reducing the risk of identity theft and spam." + } + } + } + }, + "data-broker-protection.waitlist.invited.title" : { + "comment" : "Title for Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re invited to try\nPersonal Information Removal beta!" + } + } + } + }, + "data-broker-protection.waitlist.join.subtitle.1" : { + "comment" : "First subtitle for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically scan and remove your data from 17+ sites that sell personal information with DuckDuckGo’s Personal Information Removal." + } + } + } + }, + "data-broker-protection.waitlist.join.subtitle.2" : { + "comment" : "Second subtitle for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join the waitlist, and we’ll notify you when it’s your turn." + } + } + } + }, + "data-broker-protection.waitlist.join.title" : { + "comment" : "Title for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal Beta" + } + } + } + }, + "data-broker-protection.waitlist.joined.title" : { + "comment" : "Title for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re on the list!" + } + } + } + }, + "data-broker-protection.waitlist.joined.with-notifications.subtitle.1" : { + "comment" : "Subtitle 1 for Personal Information Removal joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New invites are sent every few days, on a first come, first served basis." + } + } + } + }, + "data-broker-protection.waitlist.joined.with-notifications.subtitle.2" : { + "comment" : "Subtitle 2 for Personal Information Removal joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll notify you when your invite is ready." + } + } + } + }, + "data-broker-protection.waitlist.notification.text" : { + "comment" : "Title for Personal Information Removal waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open your invite" + } + } + } + }, + "data-broker-protection.waitlist.notification.title" : { + "comment" : "Title for Personal Information Removal waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal beta is ready!" + } + } + } + }, + "database.factory.failed.information" : { + "comment" : "Info to restart macOS after database init failure", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart your Mac and try again" + } + } + } + }, + "database.factory.failed.message" : { + "comment" : "Alert title when we fail to init database", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There was an error initializing the database" + } + } + } + }, + "default.browser.prompt.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Default…" + } + } + } + }, + "default.browser.prompt.message" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make DuckDuckGo your default browser" + } + } + } + }, + "Delete" : { + "comment" : "Command\nMain Menu Edit item" + }, + "delete-bookmark" : { + "comment" : "Delete Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete Bookmark" + } + } + } + }, + "Developer" : { + "comment" : "Main Menu " + }, + "disable" : { + "comment" : "Email protection Disable button text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Disable" + } + } + } + }, + "disable.email.protection.mesage" : { + "comment" : "Message for alert shown when user disables email protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This will only disable Autofill for Duck Addresses in this browser. \n\n You can still manually enter Duck Addresses and continue to receive forwarded email." + } + } + } + }, + "disable.email.protection.title" : { + "comment" : "Title for alert shown when user disables email protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Disable Email Protection Autofill?" + } + } + } + }, + "Display progress" : { + + }, + "dont.quit" : { + "comment" : "Don’t Quit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Don’t Quit" + } + } + } + }, + "download.finishing" : { + "comment" : "Download being finished information text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Finishing download…" + } + } + } + }, + "download.starting" : { + "comment" : "Download being initiated information text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Starting download…" + } + } + } + }, + "downloads.active.alert.message.and.others" : { + "comment" : "Alert text format element for “, and other files”", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : ", and other files" + } + } + } + }, + "downloads.active.alert.message.format" : { + "comment" : "Alert text format when trying to quit application while file “filename”[, and others] are being downloaded", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to quit? DuckDuckGo Privacy Browser is currently downloading “%1$@”%2$@. If you quit now DuckDuckGo Privacy Browser won’t finish downloading this file." + } + } + } + }, + "downloads.active.alert.title" : { + "comment" : "Alert title when trying to quit application while files are being downloaded", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A download is in progress." + } + } + } + }, + "downloads.always-ask" : { + "comment" : "Downloads preferences checkbox", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always ask where to save files" + } + } + } + }, + "downloads.change" : { + "comment" : "Change downloads directory button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change…" + } + } + } + }, + "downloads.error.canceled" : { + "comment" : "Short error description when downloaded file download was canceled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Canceled" + } + } + } + }, + "downloads.error.move.failed" : { + "comment" : "Short error description when could not move downloaded file to the Downloads folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Could not move file to Downloads" + } + } + } + }, + "downloads.error.other" : { + "comment" : "Short error description when Download failed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Error" + } + } + } + }, + "downloads.error.removed" : { + "comment" : "Short error description when downloaded file removed from Downloads folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Removed" + } + } + } + }, + "downloads.location" : { + "comment" : "Downloads directory location", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location" + } + } + } + }, + "downloads.tooltip.cancel" : { + "comment" : "Mouse-over tooltip for Cancel Download button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel Download" + } + } + } + }, + "downloads.tooltip.redownload" : { + "comment" : "Mouse-over tooltip for Download [deleted file] Again button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Download Again" + } + } + } + }, + "downloads.tooltip.restart" : { + "comment" : "Mouse-over tooltip for Restart Download button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart Download" + } + } + } + }, + "downloads.tooltip.reveal" : { + "comment" : "Mouse-over tooltip for Show in Finder button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show in Finder" + } + } + } + }, + "duck-player.always-open-in-player" : { + "comment" : "Private YouTube Player option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always open YouTube videos in Duck Player" + } + } + } + }, + "duck-player.explanation" : { + "comment" : "Private YouTube Player explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations." + } + } + } + }, + "duck-player.off" : { + "comment" : "Private YouTube Player option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never use Duck Player" + } + } + } + }, + "duck-player.show-buttons" : { + "comment" : "Private YouTube Player option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show option to use Duck Player over YouTube previews on hover" + } + } + } + }, + "duck-player.title" : { + "comment" : "Private YouTube Player settings title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Player" + } + } + } + }, + "DuckDuckGo browser version" : { + + }, + "DuckDuckGo Help" : { + "comment" : "Main Menu Help item" + }, + "DuckDuckGo needs your permission to read the %@ bookmarks file. Select the %@ folder to import bookmarks." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs your permission to read the %1$@ bookmarks file. Select the %2$@ folder to import bookmarks." + } + } + } + }, + "Duplicate Bookmarks Skipped: %lld" : { + + }, + "duplicate.tab" : { + "comment" : "Menu item. Duplicate as a verb", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duplicate Tab" + } + } + } + }, + "edit" : { + "comment" : "Edit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit" + } + } + } + }, + "Edit" : { + "comment" : "Main Menu Edit" + }, + "Edit…" : { + "comment" : "Command" + }, + "edit.favorite" : { + "comment" : "Header of the view that edits a favorite bookmark", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit Favorite" + } + } + } + }, + "edit.folder" : { + "comment" : "Header of the view that edits a bookmark folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit Folder" + } + } + } + }, + "email.copied" : { + "comment" : "Private email address was copied to clipboard message", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New address copied to your clipboard" + } + } + } + }, + "email.optionsMenu" : { + "comment" : "Menu item email feature", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Email Protection" + } + } + } + }, + "email.optionsMenu.createAddress" : { + "comment" : "Create an email alias sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Generate Private Duck Address" + } + } + } + }, + "email.optionsMenu.manageAccount" : { + "comment" : "Manage private email account sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Account" + } + } + } + }, + "email.optionsMenu.turnOff" : { + "comment" : "Disable email sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Disable Email Protection Autofill" + } + } + } + }, + "email.optionsMenu.turnOn" : { + "comment" : "Enable email sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Email Protection" + } + } + } + }, + "Enter Full Screen" : { + "comment" : "Main Menu View item" + }, + "Error message & code" : { + + }, + "error.unknown" : { + "comment" : "Error page subtitle", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An unknown error has occurred" + } + } + } + }, + "error.unknown.try.again" : { + "comment" : "Generic error message on a dialog for when the cause is not known.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An unknown error has occurred" + } + } + } + }, + "Export" : { + "comment" : "Main Menu File item" + }, + "export.bookmarks.failed.informative" : { + "comment" : "Alert message when exporting bookmarks fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please check that no file exists at the location you selected." + } + } + } + }, + "export.bookmarks.failed.message" : { + "comment" : "Alert title when exporting login data fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to Export Bookmarks…" + } + } + } + }, + "export.bookmarks.file.name.suffix" : { + "comment" : "The last part of the suggested file for exporting bookmarks", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "export.bookmarks.menu.item" : { + "comment" : "Export bookmarks menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Export Bookmarks…" + } + } + } + }, + "export.logins.data" : { + "comment" : "Opens Export Logins Data dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Export Passwords…" + } + } + } + }, + "export.logins.failed.informative" : { + "comment" : "Alert message when exporting login data fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please check that no file exists at the location you selected." + } + } + } + }, + "export.logins.failed.message" : { + "comment" : "Alert title when exporting login data fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to Export Passwords" + } + } + } + }, + "export.logins.file.name.suffix" : { + "comment" : "The last part of the suggested file name for exporting logins", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords" + } + } + } + }, + "export.logins.warning" : { + "comment" : "Warning text presented when exporting logins.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This file contains your passwords in plain text and should be saved in a secure location and deleted when you are done.\nAnyone with access to this file will be able to read your passwords." + } + } + } + }, + "Favorite This Page…" : { + "comment" : "Main Menu History item" + }, + "favorites" : { + "comment" : "Title text for the Favorites menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Favorites" + } + } + } + }, + "feedback.breakage.disclaimer" : { + "comment" : "Disclaimer in breakage form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reports sent to DuckDuckGo are 100% anonymous and only include your selection above, your optional message, the URL, a list of trackers we found on the site, the DuckDuckGo app version, and your macOS version." + } + } + } + }, + "feedback.bug.description" : { + "comment" : "Label in the feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please describe the problem in as much detail as possible:" + } + } + } + }, + "feedback.disclaimer" : { + "comment" : "Disclaimer in breakage form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reports sent to DuckDuckGo are 100% anonymous and only include your message, the DuckDuckGo app version, and your macOS version." + } + } + } + }, + "feedback.feature.request.description" : { + "comment" : "Label in the feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "What feature would you like to see?" + } + } + } + }, + "feedback.other.description" : { + "comment" : "Label in the feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please give us your feedback:" + } + } + } + }, + "File" : { + "comment" : "Main Menu File" + }, + "Find" : { + "comment" : "Main Menu Edit item" + }, + "Find Next" : { + "comment" : "Main Menu Edit-Find item" + }, + "Find Previous" : { + "comment" : "Main Menu Edit-Find item" + }, + "find.in.page" : { + "comment" : "Find in page status (e.g. 1 of 99)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d of %2$d" + } + } + } + }, + "find.in.page.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Find in Page…" + } + } + } + }, + "fire.active-tabs-info" : { + "comment" : "Info in the Fire Button popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close %1$d active %2$@ and clear all browsing history and cookies (%3$d %4$@)." + } + } + } + }, + "fire.all-data.description" : { + "comment" : "Description of the 'All Data' configuration option for the fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear all tabs and related site data" + } + } + } + }, + "fire.all-sites" : { + "comment" : "Configuration option for fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All sites" + } + } + } + }, + "fire.current-window.description" : { + "comment" : "Description of the 'Current Window' configuration option for the fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear current window and related site data" + } + } + } + }, + "fire.currentTab" : { + "comment" : "Configuration option for fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All sites visited in current tab" + } + } + } + }, + "fire.currentWindow" : { + "comment" : "Configuration option for fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All sites visited in current window" + } + } + } + }, + "fire.dialog.all-windows-will-close" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All windows will close" + } + } + } + }, + "fire.dialog.clear.sites" : { + "comment" : "Category of domains in fire button dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Selected sites will be cleared" + } + } + } + }, + "fire.dialog.details" : { + "comment" : "Button to show more details", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Details" + } + } + } + }, + "fire.dialog.fireproof.sites" : { + "comment" : "Category of domains in fire button dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof sites won't be cleared" + } + } + } + }, + "fire.dialog.tab-will-close" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Current tab will close" + } + } + } + }, + "fire.dialog.tab-will-reload" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pinned tab will reload" + } + } + } + }, + "fire.dialog.window-will-close" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Current window will close" + } + } + } + }, + "fire.one-tab-info" : { + "comment" : "Info in the Fire Button popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close this tab and clear its browsing history and cookies (%1$d %2$@)." + } + } + } + }, + "fire.select-site-to-clear" : { + "comment" : "Info label in the fire button popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select a site to clear its data." + } + } + } + }, + "fire.selected-domains.description" : { + "comment" : "Description of the 'Current Window' configuration option for the fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear data only for selected domains" + } + } + } + }, + "fireproof" : { + "comment" : "Domain fireproof status\n Fireproof button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof" + } + } + } + }, + "fireproof.checkbox.title" : { + "comment" : "Fireproof settings checkbox title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ask to Fireproof websites when signing in" + } + } + } + }, + "fireproof.confirmation.message" : { + "comment" : "Fireproof confirmation message", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproofing this site will keep you signed in after using the Fire Button." + } + } + } + }, + "fireproof.confirmation.title" : { + "comment" : "Fireproof confirmation title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Would you like to Fireproof %@?" + } + } + } + }, + "fireproof.explanation" : { + "comment" : "Fireproofing mechanism explanation", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "When you Fireproof a site, cookies won't be erased and you'll stay signed in, even after using the Fire Button." + } + } + } + }, + "fireproof.manage-sites" : { + "comment" : "Fireproof settings button caption", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Fireproof Sites…" + } + } + } + }, + "fireproof.sites" : { + "comment" : "Fireproof sites list title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof Sites" + } + } + } + }, + "folder.optionsMenu.deleteFolder" : { + "comment" : "Option for deleting a folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete Folder" + } + } + } + }, + "folder.optionsMenu.newFolder" : { + "comment" : "Option for creating a new folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Folder" + } + } + } + }, + "folder.optionsMenu.renameFolder" : { + "comment" : "Option for renaming a folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Rename Folder" + } + } + } + }, + "gpc.checkbox.title" : { + "comment" : "GPC settings checkbox title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Global Privacy Control" + } + } + } + }, + "gpc.explanation" : { + "comment" : "GPC explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tells participating websites not to sell or share your data." + } + } + } + }, + "gpc.title" : { + "comment" : "GPC settings title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Global Privacy Control (GPC)" + } + } + } + }, + "Help" : { + "comment" : "Main Menu Help" + }, + "Hide" : { + "comment" : "Main Menu > View > Home Button > None item" + }, + "Hide DuckDuckGo" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Hide Find" : { + "comment" : "Main Menu Edit-Find item" + }, + "Hide Others" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "History" : { + "comment" : "Main Menu " + }, + "history.menu.clear.all.history.description" : { + "comment" : "Description in the alert with the confirmation to clear all data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies and site data for all sites will also be cleared, unless the site is Fireproof." + } + } + } + }, + "history.menu.clear.all.history.question" : { + "comment" : "Alert with the confirmation to clear all history and data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear all history and \nclose all tabs?" + } + } + } + }, + "history.menu.clear.data.description" : { + "comment" : "Description in the alert with the confirmation to clear browsing history", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies and other data for sites visited on this day will also be cleared unless the site is Fireproof. History from other days will not be cleared." + } + } + } + }, + "history.menu.clear.data.question" : { + "comment" : "Alert with the confirmation to clear all data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear History for %@?" + } + } + } + }, + "history.menu.clear.data.today.description" : { + "comment" : "Description in the alert with the confirmation to clear browsing history", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies and other data for sites visited today will also be cleared unless the site is Fireproof. History from other days will not be cleared." + } + } + } + }, + "history.menu.clear.data.today.question" : { + "comment" : "Alert with the confirmation to clear all data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear history for today \nand close all tabs?" + } + } + } + }, + "history.menu.clear.this.history" : { + "comment" : "Menu item to clear parts of history and data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear This History…" + } + } + } + }, + "history.menu.older" : { + "comment" : "Menu item representing older history", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Older…" + } + } + } + }, + "history.menu.recently.visited" : { + "comment" : "Section header of the history menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recently Visited" + } + } + } + }, + "Home" : { + "comment" : "Main Menu View item" + }, + "Home Button" : { + "comment" : "Main Menu > View > Home Button item" + }, + "home.page.burn.fireproof.site.alert" : { + "comment" : "Message for an alert displayed when trying to burn a fireproof website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "History will be cleared for this site, but related data will remain, because this site is Fireproof" + } + } + } + }, + "home.page.clear.history" : { + "comment" : "Button caption for the burn fireproof website alert", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear History" + } + } + } + }, + "home.page.empty.state.item.message" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Keep browsing to see how many trackers were blocked" + } + } + } + }, + "home.page.empty.state.item.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recently visited sites appear here" + } + } + } + }, + "home.page.no.trackers.blocked" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No trackers blocked" + } + } + } + }, + "home.page.no.trackers.found" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No trackers found" + } + } + } + }, + "home.page.protection.duration" : { + "comment" : "Past 7 days in uppercase.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "PAST 7 DAYS" + } + } + } + }, + "home.page.protection.summary.info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No recent activity" + } + } + } + }, + "If your computer prompts you to enter a password prior to import, DuckDuckGo will not see that password.\n\nImported passwords are stored securely using encryption." : { + + }, + "Import Bookmarks" : { + + }, + "Import Bookmarks and Passwords…" : { + "comment" : "Main Menu File item" + }, + "Import Browser Data" : { + + }, + "Import Complete" : { + + }, + "Import Passwords" : { + "comment" : "my comment" + }, + "import.bookmarks.bookmarks" : { + "comment" : "Title text for the Bookmarks import option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "import.bookmarks.html.title" : { + "comment" : "Title text for the HTML Bookmarks importer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "HTML Bookmarks File" + } + } + } + }, + "import.bookmarks.indefinite.progress.text" : { + "comment" : "Operation progress info message about indefinite number of bookmarks being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing bookmarks…" + } + } + } + }, + "import.bookmarks.number.progress.text" : { + "comment" : "Operation progress info message about %d number of bookmarks being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing %d bookmarks" + } + } + } + }, + "import.bookmarks.safari.permission-button.title" : { + "comment" : "Text for the Safari data import permission button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Safari Folder…" + } + } + } + }, + "import.browser.data.bookmarks" : { + "comment" : "Opens Import Browser Data dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Bookmarks…" + } + } + } + }, + "import.browser.data.passwords" : { + "comment" : "Opens Import Browser Data dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Passwords…" + } + } + } + }, + "import.csv.instructions.bitwarden" : { + "comment" : "Instructions to import Passwords as CSV from Bitwarden.\n%d is a step number; %s is Bitwarden app name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export vault** from the Menu Bar\n%4$d Select the File Format: **.csv**\n%5$d Enter your Bitwarden Master password\n%6$d Click %7$@ and save the file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + } + } + } + }, + "import.csv.instructions.chrome" : { + "comment" : "Instructions to import Passwords as CSV from Google Chrome browser.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Google Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.chromium" : { + "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.firefox" : { + "comment" : "Instructions to import Passwords as CSV from Firefox.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords**\n%5$d Click %6$@ then **Export Logins…**\n%7$d Save the passwords file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + } + } + } + }, + "import.csv.instructions.generic" : { + "comment" : "Instructions to import a generic CSV passwords file.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The CSV importer will try to match column headers to their position.\nIf there is no header, it supports two formats:\n%1$d URL, Username, Password\n%2$d Title, URL, Username, Password\n%3$@" + } + } + } + }, + "import.csv.instructions.lastpass" : { + "comment" : "Instructions to import Passwords as CSV from LastPass.\n%d is a step number; %s is LastPass app name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Click on the **%2$s** icon in your browser and enter your master password\n%3$d Select **Open My Vault**\n%4$d From the sidebar select **Advanced Options → Export**\n%5$d Enter your LastPass master password\n%6$d Select the File Format: **Comma Delimited Text (.csv)**\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.onePassword7" : { + "comment" : "Instructions to import Passwords as CSV from 1Password 7.\n%d is a step number; %s is 1Password app name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select the vault you want to Export (You cannot export from “All Vaults.”)\n%4$d Select **File → Export → All Items** from the Menu Bar\n%5$d Enter your 1Password master or account password\n%6$d Select the File Format: **iCloud Keychain (.csv)**\n%7$d Save the passwords file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + } + } + } + }, + "import.csv.instructions.onePassword8" : { + "comment" : "Instructions to import Passwords as CSV from 1Password 8.\n%d is a step number; %s is 1Password app name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export** from the Menu Bar and choose the account you want to export\n%4$d Enter your 1Password account password\n%5$d Select the File Format: **CSV (Logins and Passwords only)**\n%6$d Click Export Data and save the file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.safari" : { + "comment" : "Instructions to import Passwords as CSV from Safari.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Passwords**\n%3$d Save the passwords file someplace you can find it (e.g. Desktop)\n%4$d %5$@" + } + } + } + }, + "import.csv.instructions.yandex" : { + "comment" : "Instructions to import Passwords as CSV from Yandex Browser.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **Yandex**\n%2$d Click %3$@ to open the application menu then click **Passwords and cards**\n%4$d Click %5$@ then **Export passwords**\n%6$d Choose **To a text file (not secure)** and click **Export**\n%7$d Save the passwords file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + } + } + } + }, + "import.data.alert.cancel" : { + "comment" : "Cancel button for data import alerts", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "import.data.alert.import" : { + "comment" : "Import button for data import alerts", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import" + } + } + } + }, + "import.data.done" : { + "comment" : "Button text for finishing the data import", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "import.data.import-failed.title" : { + "comment" : "Alert title when the data import fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sorry, we weren't able to import your data." + } + } + } + }, + "import.data.initiate" : { + "comment" : "Button text for importing data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import" + } + } + } + }, + "import.data.requires-password.body" : { + "comment" : "Alert body text when the data import needs a password", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo won't save or share your %1$@ Primary Password, but DuckDuckGo needs it to access and import passwords from %1$@." + } + } + } + }, + "import.data.requires-password.title" : { + "comment" : "Alert title text when the data import needs a password", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter Primary Password for %@" + } + } + } + }, + "import.data.skip" : { + "comment" : "Button text to skip a kind of imported data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip" + } + } + } + }, + "import.html.instructions.chromium" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Chromium-based browsers.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Bookmark Manager**\n%4$d Click %5$@ then **Export Bookmarks**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.html.instructions.firefox" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Firefox based browsers.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Manage Bookmarks**\n%4$d Click %5$@ then **Export bookmarks to HTML…**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.html.instructions.generic" : { + "comment" : "Instructions to import a generic HTML Bookmarks file.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open your old browser\n%2$d Click %3$@ then select **Bookmarks → Bookmark Manager**\n%4$d Click %5$@ then **Export bookmarks to HTML…**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.html.instructions.safari" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Safari.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Bookmarks**\n%3$d Save the passwords file someplace you can find it (e.g. Desktop)\n%4$d %5$@" + } + } + } + }, + "import.html.instructions.yandex" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Yandex Browser.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Favorites → Bookmark Manager**\n%4$d Click %5$@ then **Export bookmarks to HTML file**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.logins.csv.title" : { + "comment" : "Title text for the CSV importer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "CSV Passwords File" + } + } + } + }, + "import.logins.passwords" : { + "comment" : "Title text for the Passwords import option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords" + } + } + } + }, + "import.passwords.indefinite.progress.text" : { + "comment" : "Operation progress info message about indefinite number of passwords being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing passwords…" + } + } + } + }, + "import.passwords.number.progress.text" : { + "comment" : "Operation progress info message about %d number of passwords being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing %d passwords" + } + } + } + }, + "invite.dialog.get.started.button" : { + "comment" : "Get Started button on an invite dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "invite.dialog.unrecognized.code.message" : { + "comment" : "Message to show after user enters an unrecognized invite code", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We didn’t recognize this Invite Code." + } + } + } + }, + "JavaScript Console" : { + "comment" : "Main Menu View-Developer item" + }, + "learnmore.link" : { + "comment" : "Learn More link", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Learn More" + } + } + } + }, + "Let’s try doing it manually. It won’t take long." : { + + }, + "Login import failed: %lld" : { + + }, + "looking.for.bitwarden" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden not installed…" + } + } + } + }, + "macOS version" : { + + }, + "main.menu.close.downloads" : { + "comment" : "Hide Downloads Popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Downloads" + } + } + } + }, + "main.menu.close.inspector" : { + "comment" : "Hide Web Inspector/Close Developer Tools", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Developer Tools" + } + } + } + }, + "main.menu.show.downloads" : { + "comment" : "Show Downloads Popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Downloads" + } + } + } + }, + "main.menu.show.inspector" : { + "comment" : "Show Web Inspector/Open Developer Tools", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Developer Tools" + } + } + } + }, + "Make Lower Case" : { + "comment" : "Main Menu Edit-Transformations item" + }, + "Make Upper Case" : { + "comment" : "Main Menu Edit-Transformations item" + }, + "Manage Bookmarks" : { + "comment" : "Main Menu History item" + }, + "menu.item.new.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab" + } + } + } + }, + "Merge All Windows" : { + "comment" : "Main Menu Window item" + }, + "Minimize" : { + "comment" : "Main Menu Window item" + }, + "more-options.zoom.default-zoom-page" : { + "comment" : "Default page zoom picker title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change Default Page Zoom…" + } + } + } + }, + "more.or.less.collapse" : { + "comment" : "For collapsing views to show less.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Less" + } + } + } + }, + "more.or.less.expand" : { + "comment" : "For expanding views to show more.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show More" + } + } + } + }, + "n.more.tabs" : { + "comment" : "suffix of string in Recently Closed menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : " (and %d more tabs)" + } + } + } + }, + "navigate.back" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Back" + } + } + } + }, + "navigate.forward" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Forward" + } + } + } + }, + "network-protection.privacy-policy.section.1.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This Privacy Policy is for our limited waitlist beta VPN product.\n\nOur main [Privacy Policy](https://duckduckgo.com/privacy) also applies here." + } + } + } + }, + "network-protection.privacy-policy.section.1.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We don’t ask for any personal information from you in order to use this beta service." + } + } + } + }, + "network-protection.privacy-policy.section.2.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "That means we have no way to tie what you do online to you as an individual and we don’t have any record of things like:\n • Website visits\n • DNS requests\n • Connections made\n • IP addresses used\n • Session lengths" + } + } + } + }, + "network-protection.privacy-policy.section.2.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We don’t keep any logs of your online activity." + } + } + } + }, + "network-protection.privacy-policy.section.3.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Our servers store generic usage (for example, CPU load) and diagnostic data (for example, errors), but none of that data is connected to any individual’s activity.\n\nWe use this non-identifying information to monitor and ensure the performance and quality of the service, for example to make sure servers aren’t overloaded." + } + } + } + }, + "network-protection.privacy-policy.section.3.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We only keep anonymous performance metrics that we cannot connect to your online activity." + } + } + } + }, + "network-protection.privacy-policy.section.4.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dedicated servers means they are not shared with anyone else.\n\nWe rent our servers from providers we carefully selected because they meet our privacy requirements.\n\nWe have strict access controls in place so that only limited DuckDuckGo team members have access to our servers." + } + } + } + }, + "network-protection.privacy-policy.section.4.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We use dedicated servers for all VPN traffic." + } + } + } + }, + "network-protection.privacy-policy.section.5.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If you reach out to us for support by submitting a bug report or through email and agree to be contacted to troubleshoot the issue, we’ll contact you using the information you provide.\n\nIf you participate in a voluntary product survey or questionnaire and agree to provide further feedback, we may contact you using the information you provide.\n\nWe will permanently delete all personal information you provided to us (email, contact information), within 30 days after closing a support case or, in the case of follow up feedback, within 60 days after ending this beta service." + } + } + } + }, + "network-protection.privacy-policy.section.5.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We protect and limit use of your data when you communicate directly with DuckDuckGo." + } + } + } + }, + "network-protection.privacy-policy.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Policy" + } + } + } + }, + "network-protection.terms-of-service.section.1.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This service is provided for your personal use only.\n\nYou are responsible for all activity in the service that occurs on or through your device.\n\nThis service may only be used through the DuckDuckGo app on the device on which you are given access. If you delete the DuckDuckGo app, you will lose access to the service.\n\nYou may not use this service through a third-party client." + } + } + } + }, + "network-protection.terms-of-service.section.1.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The service is for limited and personal use only." + } + } + } + }, + "network-protection.terms-of-service.section.2.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You agree that you will not use the service for any unlawful, illicit, criminal, or fraudulent purpose, or in any manner that could give rise to civil or criminal liability under applicable law.\n\nYou agree to comply with our [DuckDuckGo Terms of Service](https://duckduckgo.com/terms), which are incorporated by reference." + } + } + } + }, + "network-protection.terms-of-service.section.2.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You agree to comply with all applicable laws, rules, and regulations." + } + } + } + }, + "network-protection.terms-of-service.section.3.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Access to this beta is randomly awarded. You are responsible for ensuring eligibility.\n\nYou must be at least 18 years old and live in a location where use of a VPN is legal in order to be eligible to use this service." + } + } + } + }, + "network-protection.terms-of-service.section.3.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You must be eligible to use this service." + } + } + } + }, + "network-protection.terms-of-service.section.4.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This service is provided as-is and without warranties or guarantees of any kind.\n\nTo the extent possible under applicable law, DuckDuckGo will not be liable for any damage or loss arising from your use of the service. In any event, the total aggregate liability of DuckDuckGo shall not exceed $25 or the equivalent in your local currency.\n\nWe may in the future transfer responsibility for the service to a subsidiary of DuckDuckGo. If that happens, you agree that references to “DuckDuckGo” will refer to our subsidiary, which will then become responsible for providing the service and for any liabilities relating to it." + } + } + } + }, + "network-protection.terms-of-service.section.4.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We provide this beta service as-is and without warranty." + } + } + } + }, + "network-protection.terms-of-service.section.5.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We reserve the right to revoke access to the service at any time in our sole discretion.\n\nWe may also terminate access for violation of these terms, including for repeated infringement of the intellectual property rights of others." + } + } + } + }, + "network-protection.terms-of-service.section.5.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We may terminate access at any time." + } + } + } + }, + "network-protection.terms-of-service.section.6.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Access to this service is currently free of charge, but that is limited to this beta period.\n\nYou understand and agree that this service is provided on a temporary, testing basis only." + } + } + } + }, + "network-protection.terms-of-service.section.6.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The service is free during the beta period." + } + } + } + }, + "network-protection.terms-of-service.section.7.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The service is in beta, and we are regularly changing it.\n\nService coverage, speed, server locations, and quality may vary without warning." + } + } + } + }, + "network-protection.terms-of-service.section.7.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We are continually updating the service." + } + } + } + }, + "network-protection.terms-of-service.section.8.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You may be asked during the beta period to provide feedback about your experience. Doing so is optional and your feedback may be used to improve the service.\n\nIf you have enabled notifications for the DuckDuckGo app, we may use notifications to ask about your experience. You can disable notifications if you do not want to receive them." + } + } + } + }, + "network-protection.terms-of-service.section.8.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We need your feedback." + } + } + } + }, + "network-protection.terms-of-service.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Terms of Service" + } + } + } + }, + "network-protection.waitlist.availability-disclaimer" : { + "comment" : "Availability disclaimer for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection is free to use during the beta." + } + } + } + }, + "network-protection.waitlist.button.agree-and-continue" : { + "comment" : "Agree and Continue button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Agree and Continue" + } + } + } + }, + "network-protection.waitlist.button.cancel" : { + "comment" : "Cancel button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "network-protection.waitlist.button.close" : { + "comment" : "Close button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close" + } + } + } + }, + "network-protection.waitlist.button.dismiss" : { + "comment" : "Dismiss button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dismiss" + } + } + } + }, + "network-protection.waitlist.button.done" : { + "comment" : "Close button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "network-protection.waitlist.button.enable-notifications" : { + "comment" : "Enable Notifications button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Notifications" + } + } + } + }, + "network-protection.waitlist.button.get-started" : { + "comment" : "Get Started button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "network-protection.waitlist.button.got-it" : { + "comment" : "Got It button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Got It" + } + } + } + }, + "network-protection.waitlist.button.join-waitlist" : { + "comment" : "Join Waitlist button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join the Waitlist" + } + } + } + }, + "network-protection.waitlist.button.no-thanks" : { + "comment" : "No Thanks button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Thanks" + } + } + } + }, + "network-protection.waitlist.enable-notifications" : { + "comment" : "Enable notifications prompt for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want to get a notification when your Network Protection invite is ready?" + } + } + } + }, + "network-protection.waitlist.enable.subtitle" : { + "comment" : "Subtitle for Network Protection enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Look for the globe icon in the browser toolbar or in the Mac menu bar.\n\nYou'll be asked to Allow a VPN connection once when setting up Network Protection the first time." + } + } + } + }, + "network-protection.waitlist.enable.title" : { + "comment" : "Title for Network Protection enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ready to enable Network Protection?" + } + } + } + }, + "network-protection.waitlist.invited.section-1.subtitle" : { + "comment" : "Subtitle for section 1 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Encrypt online traffic across your browsers and apps." + } + } + } + }, + "network-protection.waitlist.invited.section-1.title" : { + "comment" : "Title for section 1 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Full-device coverage" + } + } + } + }, + "network-protection.waitlist.invited.section-2.subtitle" : { + "comment" : "Subtitle for section 2 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No need for a separate app. Connect in one click and see your connection status at a glance." + } + } + } + }, + "network-protection.waitlist.invited.section-2.title" : { + "comment" : "Title for section 2 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fast, reliable, and easy to use" + } + } + } + }, + "network-protection.waitlist.invited.section-3.subtitle" : { + "comment" : "Subtitle for section 3 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We do not log or save any data that can connect you to your online activity." + } + } + } + }, + "network-protection.waitlist.invited.section-3.title" : { + "comment" : "Title for section 3 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Strict no-logging policy" + } + } + } + }, + "network-protection.waitlist.invited.subtitle" : { + "comment" : "Subtitle for Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get an extra layer of protection online with the VPN built for speed and simplicity. Encrypt your internet connection across your entire device and hide your location and IP address from sites you visit." + } + } + } + }, + "network-protection.waitlist.invited.title" : { + "comment" : "Title for Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re invited to try\nNetwork Protection beta!" + } + } + } + }, + "network-protection.waitlist.join.subtitle.1" : { + "comment" : "First subtitle for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Secure your connection anytime, anywhere with Network Protection, the VPN from DuckDuckGo." + } + } + } + }, + "network-protection.waitlist.join.subtitle.2" : { + "comment" : "Second subtitle for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join the waitlist, and we’ll notify you when it’s your turn." + } + } + } + }, + "network-protection.waitlist.join.title" : { + "comment" : "Title for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection Beta" + } + } + } + }, + "network-protection.waitlist.joined.title" : { + "comment" : "Title for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re on the list!" + } + } + } + }, + "network-protection.waitlist.joined.with-notifications.subtitle.1" : { + "comment" : "Subtitle 1 for Network Protection joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New invites are sent every few days, on a first come, first served basis." + } + } + } + }, + "network-protection.waitlist.joined.with-notifications.subtitle.2" : { + "comment" : "Subtitle 2 for Network Protection joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll notify you when your invite is ready." + } + } + } + }, + "network-protection.waitlist.notification.text" : { + "comment" : "Title for Network Protection waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open your invite" + } + } + } + }, + "network-protection.waitlist.notification.title" : { + "comment" : "Title for Network Protection waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection beta is ready!" + } + } + } + }, + "network.protection" : { + "comment" : "Menu item for opening Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection" + } + } + } + }, + "network.protection.configuration.system-settings.legacy" : { + "comment" : "Text for a label in the Network Protection popover, displayed after attempting to enable Network Protection for the first time while using macOS 12 and below", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Go to Security & Privacy in System Preferences to allow Network Protection to activate" + } + } + } + }, + "network.protection.configuration.system-settings.modern" : { + "comment" : "Text for a label in the Network Protection popover, displayed after attempting to enable Network Protection for the first time while using macOS 13 and above", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Go to Privacy & Security in System Settings to allow Network Protection to activate" + } + } + } + }, + "network.protection.invite.dialog.message" : { + "comment" : "Message for the network protection invite dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter your invite code to get started." + } + } + } + }, + "network.protection.invite.dialog.title" : { + "comment" : "Title for the network protection invite dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You've unlocked a beta feature!" + } + } + } + }, + "network.protection.invite.field.prompt" : { + "comment" : "Prompt for the network protection invite code text field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Code" + } + } + } + }, + "network.protection.invite.success.title" : { + "comment" : "Message for the network protection invite success view\n Title for the network protection invite success view", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Success! You’re in." + } + } + } + }, + "network.protection.navbar.status.view.share.feedback" : { + "comment" : "Menu item for 'Share Feedback' in the Network Protection status view that's shown in the navigation bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Share Feedback..." + } + } + } + }, + "network.protection.status.button.tooltip" : { + "comment" : "The tooltip for NetP's nav bar button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection" + } + } + } + }, + "network.protection.system.extension.please.reboot" : { + "comment" : "Message shown to users when they try to enable NetP and they need to reboot the computer to complete the installation", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please reboot to activate Network Protection" + } + } + } + }, + "network.protection.system.extension.unknown.activation.error" : { + "comment" : "Message shown to users when they try to enable NetP and there is an unexpected activation error.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There as an unexpected error. Please try again." + } + } + } + }, + "network.protection.tunnel.name" : { + "comment" : "The name of the NetP VPN that will be visible in the system to the user", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo Network Protection" + } + } + } + }, + "New Tab" : { + "comment" : "Main Menu File item" + }, + "new.burner.window.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Fire Window" + } + } + } + }, + "new.window.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Window" + } + } + } + }, + "newTab.bottom.popover.title" : { + "comment" : "Title of the popover that appears when pressing the bottom right button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab Page" + } + } + } + }, + "newTab.favorites.section.title" : { + "comment" : "Title of the Favorites section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Favorites" + } + } + } + }, + "newTab.menu.item.show.continue.setup" : { + "comment" : "Title of the menu item in the home page to show/hide continue setup section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Next Steps" + } + } + } + }, + "newTab.menu.item.show.favorite" : { + "comment" : "Title of the menu item in the home page to show/hide favorite section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Favorites" + } + } + } + }, + "newTab.menu.item.show.recent.activity" : { + "comment" : "Title of the menu item in the home page to show/hide recent activity section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Recent Activity" + } + } + } + }, + "newTab.recent.activity.section.title" : { + "comment" : "Title of the RecentActivity section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recent Activity" + } + } + } + }, + "newTab.setup.cookie.manager.action" : { + "comment" : "Action title on the action menu of the Cookie Manager card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Handle Pop-ups For Me" + } + } + } + }, + "newTab.setup.cookie.manager.summary" : { + "comment" : "Summary of the Cookie Manager card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We need your permission to say no to cookies on your behalf. Easy choice." + } + } + } + }, + "newTab.setup.cookie.manager.title" : { + "comment" : "Title of the Cookie Manager card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Let Us Handle Cookie Pop-ups" + } + } + } + }, + "newTab.setup.default.browser.action" : { + "comment" : "Action title on the action menu of the Default Browser card", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make Default Browser" + } + } + } + }, + "newTab.setup.default.browser.summary" : { + "comment" : "Summary of the Default Browser card", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We automatically block trackers as you browse. It's privacy, simplified." + } + } + } + }, + "newTab.setup.default.browser.title" : { + "comment" : "Title of the Default Browser card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Default to Privacy" + } + } + } + }, + "newTab.setup.duck.player.action" : { + "comment" : "Action title on the action menu of the Duck Player card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try Duck Player" + } + } + } + }, + "newTab.setup.duck.player.summary" : { + "comment" : "Summary of the Duck Player card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enjoy a clean viewing experience without personalized ads." + } + } + } + }, + "newTab.setup.duck.player.title" : { + "comment" : "Title of the Duck Player card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clean Up YouTube" + } + } + } + }, + "newTab.setup.email.protection.action" : { + "comment" : "Action title on the action menu of the Email Protection card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get a Duck Address" + } + } + } + }, + "newTab.setup.email.protection.summary" : { + "comment" : "Summary of the Email Protection card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Generate custom @duck.com addresses that clean trackers from incoming email." + } + } + } + }, + "newTab.setup.email.protection.title" : { + "comment" : "Title of the Email Protection card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Protect Your Inbox" + } + } + } + }, + "newTab.setup.Import.action" : { + "comment" : "Action title on the action menu of the Import card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Now" + } + } + } + }, + "newTab.setup.import.summary" : { + "comment" : "Summary of the Import card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import bookmarks, favorites, and passwords from your old browser." + } + } + } + }, + "newTab.setup.import.title" : { + "comment" : "Title of the Import card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bring Your Stuff" + } + } + } + }, + "newTab.setup.remove.item" : { + "comment" : "Action title on the action menu of the set up cards card of the SetUp section in the home page to remove the item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dismiss" + } + } + } + }, + "newTab.setup.section.title" : { + "comment" : "Title of the setup section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next Steps" + } + } + } + }, + "newTab.setup.survey.day.0.action" : { + "comment" : "Action title of the Day 0 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Share Your Thoughts" + } + } + } + }, + "newTab.setup.survey.day.0.summary" : { + "comment" : "Summary of the Day 0 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Take our short survey and help us build the best browser." + } + } + } + }, + "newTab.setup.survey.day.0.title" : { + "comment" : "Title of the Day 0 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tell Us What Brought You Here" + } + } + } + }, + "newTab.setup.survey.day.7.action" : { + "comment" : "Action title of the Day 7 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Share Your Thoughts" + } + } + } + }, + "newTab.setup.survey.day.7.summary" : { + "comment" : "Summary of the Day 7 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Take our short survey and help us build the best browser." + } + } + } + }, + "newTab.setup.survey.day.7.title" : { + "comment" : "Title of the Day 7 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Help Us Improve" + } + } + } + }, + "next" : { + "comment" : "Next button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next" + } + } + } + }, + "no.access.to.downloads.folder.header" : { + "comment" : "Header of the alert dialog informing user about failed download", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs permission to access your Downloads folder" + } + } + } + }, + "no.access.to.downloads.folder.legacy" : { + "comment" : "Alert presented to user if the app doesn't have rights to access Downloads folder. This is used for macOS version 12 and below", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Grant access in Security & Privacy preferences in System Settings." + } + } + } + }, + "no.access.to.downloads.folder.modern" : { + "comment" : "Alert presented to user if the app doesn't have rights to access Downloads folder. This is used for macOS version 13 and above", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Grant access in Privacy & Security preferences in System Settings." + } + } + } + }, + "no.access.to.selected.folder" : { + "comment" : "Alert presented to user if the app doesn't have rights to access selected folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Grant access to the location of download." + } + } + } + }, + "no.access.to.selected.folder.header" : { + "comment" : "Header of the alert dialog informing user about failed download", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs permission to access selected folder" + } + } + } + }, + "notification.badge.cookiesmanaged" : { + "comment" : "Notification that appears when browser automatically handle cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies Managed" + } + } + } + }, + "notification.badge.popuphidden" : { + "comment" : "Notification that appears when browser cosmetically hides a cookie popup", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pop-up Hidden" + } + } + } + }, + "notnow" : { + "comment" : "Not Now button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Not Now" + } + } + } + }, + "ok" : { + "comment" : "OK button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "OK" + } + } + } + }, + "onboarding.importdata.button" : { + "comment" : "Launch the import data UI", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import" + } + } + } + }, + "onboarding.importdata.text" : { + "comment" : "Call to action to import data from other browsers", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "First, let me help you import your bookmarks 📖 and passwords 🔑 from those less private browsers." + } + } + } + }, + "onboarding.notnow.button" : { + "comment" : "Skip a step of the onboarding flow", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Maybe Later" + } + } + } + }, + "onboarding.setdefault.button" : { + "comment" : "Launch the set default UI", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Let's Do It!" + } + } + } + }, + "onboarding.setdefault.text" : { + "comment" : "Call to action to set the browser as default", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next, try setting DuckDuckGo as your default️ browser, so you can open links with peace of mind, every time." + } + } + } + }, + "onboarding.startbrowsing.text" : { + "comment" : "Call to action to start using the app as a browser", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re all set!\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possibleu{00A0}🔒" + } + } + } + }, + "onboarding.welcome.button" : { + "comment" : "Start the onboarding flow", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "onboarding.welcome.text" : { + "comment" : "Detailed welcome to the app text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tired of being tracked online? You've come to the right place 👍\n\nI'll help you stay private️ as you search and browse the web. Trackers be gone!" + } + } + } + }, + "onboarding.welcome.title" : { + "comment" : "General welcome to the app title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Welcome to DuckDuckGo!" + } + } + } + }, + "one.more.tab" : { + "comment" : "suffix of string in Recently Closed menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : " (and 1 more tab)" + } + } + } + }, + "open" : { + "comment" : "Open button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open" + } + } + } + }, + "Open Location…" : { + "comment" : "Main Menu File item" + }, + "open.bitwarden" : { + "comment" : "Button to open Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bitwarden" + } + } + } + }, + "open.bitwarden.and.log.in.or.unlock" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bitwarden and Log in or Unlock your vault." + } + } + } + }, + "open.externally.failed" : { + "comment" : "’Link’ is link on a website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The app required to open that link can’t be found" + } + } + } + }, + "open.image.in.new.burner.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Image in New Fire Tab" + } + } + } + }, + "open.image.in.new.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Image in New Tab" + } + } + } + }, + "open.in" : { + "comment" : "Opening an entity in other application", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in %@" + } + } + } + }, + "open.in.new.tab" : { + "comment" : "Menu item that opens the link in a new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in New Tab" + } + } + } + }, + "open.in.new.window" : { + "comment" : "Menu item that opens the link in a new window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in New Window" + } + } + } + }, + "open.link.in.new.burner.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Link in New Fire Tab" + } + } + } + }, + "open.link.in.new.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Link in New Tab" + } + } + } + }, + "open.preferences" : { + "comment" : "Open System Preferences (to re-enable permission for the App) (up to and including macOS 12", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open System Preferences" + } + } + } + }, + "open.settings" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open System Settings…" + } + } + } + }, + "options.menu.fireproof-site" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof This Site" + } + } + } + }, + "options.menu.move.tab.to.new.window" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Move Tab to New Window" + } + } + } + }, + "options.menu.remove-fireproofing" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Fireproofing" + } + } + } + }, + "passsword.management" : { + "comment" : "Used as title for password management user interface", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autofill" + } + } + } + }, + "passsword.management.all-items" : { + "comment" : "Used as title for the Autofill All Items option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All Items" + } + } + } + }, + "passsword.management.credit-cards" : { + "comment" : "Used as title for the Autofill Credit Cards option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Credit Cards" + } + } + } + }, + "passsword.management.identities" : { + "comment" : "Used as title for the Autofill Identities option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Identities" + } + } + } + }, + "passsword.management.lock" : { + "comment" : "Lock Logins Vault menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Lock" + } + } + } + }, + "passsword.management.logins" : { + "comment" : "Used as title for the Autofill Logins option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logins" + } + } + } + }, + "passsword.management.notes" : { + "comment" : "Used as title for the Autofill Notes option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notes" + } + } + } + }, + "passsword.management.unlock" : { + "comment" : "Unlock Logins Vault menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unlock" + } + } + } + }, + "password.manager" : { + "comment" : "Section header", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password Manager" + } + } + } + }, + "Passwords import should fail" : { + + }, + "Passwords: %lld" : { + + }, + "Passwords…" : { + "comment" : "Main Menu File-Export item" + }, + "Paste" : { + "comment" : "Main Menu Edit item" + }, + "Paste and Match Style" : { + "comment" : "Main Menu Edit item" + }, + "paste-from-clipboard" : { + "comment" : "Paste button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paste from Clipboard" + } + } + } + }, + "paste.and.go" : { + "comment" : "Paste & Go button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paste & Go" + } + } + } + }, + "paste.and.search" : { + "comment" : "Paste & Search button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paste & Search" + } + } + } + }, + "permission.allow.externalScheme" : { + "comment" : "Allow the App Name(%@ 1) to open “URL Scheme”(%@ 2) links\n Allow to open External Link (%@ 2) to open on current domain (%@ 1)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow “%1$@“ to open %2$@" + } + } + } + }, + "permission.authorization.externalScheme.empty.format" : { + "comment" : "Popover asking to open link in External App (%@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open this link in %@?" + } + } + } + }, + "permission.authorization.externalScheme.format" : { + "comment" : "Popover asking for domain %@ to open link in External App (%@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "“%1$@” would like to open this link in %2$@" + } + } + } + }, + "permission.authorization.format" : { + "comment" : "Popover asking for domain %@ to use camera/mic/location (%@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow “%1$@“ to use your %2$@?" + } + } + } + }, + "permission.authorization.popups" : { + "comment" : "Popover asking for domain %@ to open Popup Window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow “%@“ to open PopUp Window?" + } + } + } + }, + "permission.camera" : { + "comment" : "Camera input media device name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera" + } + } + } + }, + "permission.cameraAndmicrophone" : { + "comment" : "camera and microphone input media devices name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera and Microphone" + } + } + } + }, + "permission.disabled.app" : { + "comment" : "The app (DuckDuckGo: %@ 2) has no access permission to (%@ 1) media device", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ access is disabled for %2$@" + } + } + } + }, + "permission.disabled.system" : { + "comment" : "Geolocation Services are disabled in System Preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "System location services are disabled" + } + } + } + }, + "permission.externalScheme.open.format" : { + "comment" : "Open %@ App Name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open %@" + } + } + } + }, + "permission.geolocation" : { + "comment" : "User's Geolocation permission access name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location" + } + } + } + }, + "permission.microphone" : { + "comment" : "Microphone input media device name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Microphone" + } + } + } + }, + "permission.mute" : { + "comment" : "Temporarily pause input media device %@ access for %@2 website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pause %1$@ use on “%2$@”" + } + } + } + }, + "permission.open.settings" : { + "comment" : "Open System Settings (to re-enable permission for the App) (macOS 13 and above)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open System Settings" + } + } + } + }, + "permission.popover.deny" : { + "comment" : "Permission Popover: Deny Website input media device access", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deny" + } + } + } + }, + "permission.popup.open.format" : { + "comment" : "Open %@ URL Pop-up", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@" + } + } + } + }, + "permission.popup.title" : { + "comment" : "List of blocked popups Title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Blocked Pop-ups" + } + } + } + }, + "permission.popups" : { + "comment" : "Open Pop Up Windows permission access name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pop-ups" + } + } + } + }, + "permission.reloadPage" : { + "comment" : "Reload webpage to ask for input media device access permission again", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reload to ask permission again" + } + } + } + }, + "permission.unmute" : { + "comment" : "Resume input media device %@ access for %@ website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Resume %1$@ use on “%2$@”" + } + } + } + }, + "pin.tab" : { + "comment" : "Menu item. Pin as a verb", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pin Tab" + } + } + } + }, + "pinning.hide-autofill-shortcut" : { + "comment" : "Menu item for hiding the autofill shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Autofill Shortcut" + } + } + } + }, + "pinning.hide-bookmarks-shortcut" : { + "comment" : "Menu item for hiding the bookmarks shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Bookmarks Shortcut" + } + } + } + }, + "pinning.hide-downloads-shortcut" : { + "comment" : "Menu item for hiding the downloads shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Downloads Shortcut" + } + } + } + }, + "pinning.hide-home-shortcut" : { + "comment" : "Menu item for hiding the Home shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Home Button" + } + } + } + }, + "pinning.hide-netp-shortcut" : { + "comment" : "Menu item for hiding the NetP shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Network Protection" + } + } + } + }, + "pinning.show-autofill-shortcut" : { + "comment" : "Menu item for showing the autofill shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Autofill Shortcut" + } + } + } + }, + "pinning.show-bookmarks-shortcut" : { + "comment" : "Menu item for showing the bookmarks shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Bookmarks Shortcut" + } + } + } + }, + "pinning.show-downloads-shortcut" : { + "comment" : "Menu item for showing the downloads shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Downloads Shortcut" + } + } + } + }, + "pinning.show-home-shortcut" : { + "comment" : "Menu item for showing the Home shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Home Button" + } + } + } + }, + "pinning.show-netp-shortcut" : { + "comment" : "Menu item for showing the NetP shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Network Protection" + } + } + } + }, + "Please submit a report to help us fix the issue." : { + + }, + "pm.activate" : { + "comment" : "Activate button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reactivate" + } + } + } + }, + "pm.activate.private.email" : { + "comment" : "Activate private email address button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reactivate Duck Address" + } + } + } + }, + "pm.added" : { + "comment" : "Label for login added data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Added" + } + } + } + }, + "pm.address.address1" : { + "comment" : "Label for address 1 title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address 1" + } + } + } + }, + "pm.address.address2" : { + "comment" : "Label for address 2 title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address 2" + } + } + } + }, + "pm.address.city" : { + "comment" : "Label for city title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "City" + } + } + } + }, + "pm.address.postal-code" : { + "comment" : "Label for postal code title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Postal Code" + } + } + } + }, + "pm.address.state-province" : { + "comment" : "Label for state/province title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "State/Province" + } + } + } + }, + "pm.cancel" : { + "comment" : "Cancel button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "pm.card.cardholder-name" : { + "comment" : "Label for cardholder name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cardholder Name" + } + } + } + }, + "pm.card.cvv" : { + "comment" : "Label for CVV title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "CVV" + } + } + } + }, + "pm.card.expiration-date" : { + "comment" : "Label for expiration date title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Expiration Date" + } + } + } + }, + "pm.card.number" : { + "comment" : "Label for card number title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Card Number" + } + } + } + }, + "pm.day" : { + "comment" : "Label for Day title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Day" + } + } + } + }, + "pm.deactivate" : { + "comment" : "Deactivate button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deactivate" + } + } + } + }, + "pm.deactivate.private.email" : { + "comment" : "Deactivate private email address button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deactivate Duck Address" + } + } + } + }, + "pm.delete" : { + "comment" : "Delete button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete" + } + } + } + }, + "pm.edit" : { + "comment" : "Edit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit" + } + } + } + }, + "pm.email-address" : { + "comment" : "Label for email address title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Email Address" + } + } + } + }, + "pm.empty.cards.title" : { + "comment" : "Label for cards empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Cards" + } + } + } + }, + "pm.empty.default.description" : { + "comment" : "Label for default empty state description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If your logins are saved in another browser, you can import them into DuckDuckGo." + } + } + } + }, + "pm.empty.default.title" : { + "comment" : "Label for default empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Logins or Payment Methods saved yet" + } + } + } + }, + "pm.empty.identities.title" : { + "comment" : "Label for identities empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Identities" + } + } + } + }, + "pm.empty.logins.title" : { + "comment" : "Label for logins empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Logins" + } + } + } + }, + "pm.empty.notes.title" : { + "comment" : "Label for notes empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Notes" + } + } + } + }, + "pm.enable.email.protection" : { + "comment" : "Text link to email protection website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Email Protection" + } + } + } + }, + "pm.identification" : { + "comment" : "Label for identification title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Identification" + } + } + } + }, + "pm.identity.autofill.title.default" : { + "comment" : "Default title for Addresses/Identities", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address" + } + } + } + }, + "pm.last.updated" : { + "comment" : "Label for last updated edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last Updated" + } + } + } + }, + "pm.lock-screen.duration" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your Autofill info will remain unlocked until your computer is idle for %@." + } + } + } + }, + "pm.lock-screen.preferences.label" : { + "comment" : "Label used for a button that opens preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change in" + } + } + } + }, + "pm.lock-screen.preferences.link" : { + "comment" : "Label used for a button that opens preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "pm.lock-screen.prompt.autofill" : { + "comment" : "Label presented when autofilling credit card information", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "unlock access to your Autofill info" + } + } + } + }, + "pm.lock-screen.prompt.change-settings" : { + "comment" : "Label presented when changing Auto-Lock settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "change your Autofill info access settings" + } + } + } + }, + "pm.lock-screen.prompt.export-logins" : { + "comment" : "Label presented when exporting logins", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "export your usernames and passwords" + } + } + } + }, + "pm.lock-screen.prompt.unlock-logins" : { + "comment" : "Label presented when unlocking Autofill", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "unlock access to your Autofill info" + } + } + } + }, + "pm.lock-screen.threshold.1-hour" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "1 hour" + } + } + } + }, + "pm.lock-screen.threshold.1-minute" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "1 minute" + } + } + } + }, + "pm.lock-screen.threshold.5-minutes" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "5 minutes" + } + } + } + }, + "pm.lock-screen.threshold.12-hours" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "12 hours" + } + } + } + }, + "pm.lock-screen.threshold.15-minutes" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "15 minutes" + } + } + } + }, + "pm.lock-screen.threshold.30-minutes" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "30 minutes" + } + } + } + }, + "pm.month" : { + "comment" : "Label for Month title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Month" + } + } + } + }, + "pm.name.first" : { + "comment" : "Label for first name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "First Name" + } + } + } + }, + "pm.name.last" : { + "comment" : "Label for last name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last Name" + } + } + } + }, + "pm.name.middle" : { + "comment" : "Label for middle name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Middle Name" + } + } + } + }, + "pm.new.card" : { + "comment" : "Label for new card title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Credit Card" + } + } + } + }, + "pm.new.identity" : { + "comment" : "Label for new identity title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Identity" + } + } + } + }, + "pm.new.login" : { + "comment" : "Label for new login title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Login" + } + } + } + }, + "pm.new.note" : { + "comment" : "Label for new note title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "pm.note" : { + "comment" : "Label for note title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "pm.note.empty" : { + "comment" : "Label for empty note title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Empty note" + } + } + } + }, + "pm.notes" : { + "comment" : "Label for notes edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notes" + } + } + } + }, + "pm.password" : { + "comment" : "Label for password edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password" + } + } + } + }, + "pm.phone-number" : { + "comment" : "Label for phone number title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Phone Number" + } + } + } + }, + "pm.private.email.mesage.activate.confirm.content" : { + "comment" : "Text for the confirmation message displayed when a user tries activate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Emails sent to %@ will again be forwarded to your inbox." + } + } + } + }, + "pm.private.email.mesage.activate.confirm.title" : { + "comment" : "Title for the confirmation message displayed when a user tries activate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reactivate Private Duck Address?" + } + } + } + }, + "pm.private.email.mesage.active" : { + "comment" : "Mesasage displayed when a private email address is active", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Address Active" + } + } + } + }, + "pm.private.email.mesage.deactivate.confirm.content" : { + "comment" : "Text for the confirmation message displayed when a user tries deactivate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Emails sent to %@ will no longer be forwarded to your inbox." + } + } + } + }, + "pm.private.email.mesage.deactivate.confirm.title" : { + "comment" : "Title for the confirmation message displayed when a user tries deactivate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deactivate Private Duck Address?" + } + } + } + }, + "pm.private.email.mesage.error" : { + "comment" : "Mesasage displayed when a user tries to manage a private email address but the service is not available, returns an error or network is down", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Management of this address is temporarily unavailable." + } + } + } + }, + "pm.private.email.mesage.inactive" : { + "comment" : "Mesasage displayed when a private email address is inactive", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Address Deactivated" + } + } + } + }, + "pm.removed.duck.address.button" : { + "comment" : "Button text for the alert dialog telling the user an updated username is no longer a private email address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Got it" + } + } + } + }, + "pm.removed.duck.address.content" : { + "comment" : "Content for the alert dialog telling the user an updated username is no longer a private email address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You can still manage this Duck Address from emails received from it in your personal inbox." + } + } + } + }, + "pm.removed.duck.address.title" : { + "comment" : "Title for the alert dialog telling the user an updated username is no longer a private email address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Private Duck Address username was removed" + } + } + } + }, + "pm.save" : { + "comment" : "Save button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "pm.save-credentials.editable.title" : { + "comment" : "Title for the editable Save Credentials popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save Login?" + } + } + } + }, + "pm.save-credentials.non-editable.title" : { + "comment" : "Title for the non-editable Save Credentials popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Login Saved" + } + } + } + }, + "pm.signin.to.manage" : { + "comment" : "Message displayed to the user when they are logged out of Email protection.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ to manage your Duck Addresses on this device." + } + } + } + }, + "pm.sort.date.ascending" : { + "comment" : "Label for Ascending date sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Newest First" + } + } + } + }, + "pm.sort.date.descending" : { + "comment" : "Label for Descending date sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Oldest First" + } + } + } + }, + "pm.sort.parameter.date-created" : { + "comment" : "Label for Date Created sort parameter", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Date Created" + } + } + } + }, + "pm.sort.parameter.date-modified" : { + "comment" : "Label for Date Modified sort parameter", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Date Modified" + } + } + } + }, + "pm.sort.parameter.title" : { + "comment" : "Label for Title sort parameter", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Title" + } + } + } + }, + "pm.sort.string.ascending" : { + "comment" : "Label for Ascending string sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Alphabetically" + } + } + } + }, + "pm.sort.string.descending" : { + "comment" : "Label for Descending string sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reverse Alphabetically" + } + } + } + }, + "pm.username" : { + "comment" : "Label for username edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Username" + } + } + } + }, + "pm.website" : { + "comment" : "Label for website edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Website URL" + } + } + } + }, + "pm.year" : { + "comment" : "Label for Year title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Year" + } + } + } + }, + "preferences-homepage" : { + "comment" : "Homepage behavior description\n Title for Homepage section in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Homepage" + } + } + } + }, + "preferences-homepage-address" : { + "comment" : "Homepage address field label", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address:" + } + } + } + }, + "preferences-homepage-customPage" : { + "comment" : "Option to control Specific Home Page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Specific page" + } + } + } + }, + "preferences-homepage-newTab" : { + "comment" : "Option to open a new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab page" + } + } + } + }, + "preferences-homepage-set-homePage" : { + "comment" : "Set Homepage dialog title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Homepage" + } + } + } + }, + "preferences-homepage-set-page" : { + "comment" : "Option to control the Specific Page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Page…" + } + } + } + }, + "Preferences…" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "preferences.about" : { + "comment" : "Show about screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "About" + } + } + } + }, + "preferences.about.about-duckduckgo" : { + "comment" : "About screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "About DuckDuckGo" + } + } + } + }, + "preferences.about.more-at" : { + "comment" : "Link to the about page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More at %@" + } + } + } + }, + "preferences.about.privacy-policy" : { + "comment" : "Link to privacy policy page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Policy" + } + } + } + }, + "preferences.about.privacy-simplified" : { + "comment" : "About screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy, simplified." + } + } + } + }, + "preferences.about.send-feedback" : { + "comment" : "Feedback button in the about preferences page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Send Feedback" + } + } + } + }, + "preferences.about.unsupported-device-info1" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo is no longer providing browser updates for your version of macOS." + } + } + } + }, + "preferences.about.unsupported-device-info2-part1" : { + "comment" : "Second paragraph of unsupported device info - sentence part 1", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please" + } + } + } + }, + "preferences.about.unsupported-device-info2-part2" : { + "comment" : "Second paragraph of unsupported device info - sentence part 2 (underlined)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "update to macOS %@" + } + } + } + }, + "preferences.about.unsupported-device-info2-part3" : { + "comment" : "Second paragraph of unsupported device info - sentence part 3\n Second paragraph of unsupported device info - sentence part 4", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "or later to use the most recent version" + } + } + } + }, + "preferences.appearance" : { + "comment" : "Show appearance preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Appearance" + } + } + } + }, + "preferences.appearance.address-bar" : { + "comment" : "Theme preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address Bar" + } + } + } + }, + "preferences.appearance.show-autocomplete-suggestions" : { + "comment" : "Option to show autocomplete suggestions in the address bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autocomplete suggestions" + } + } + } + }, + "preferences.appearance.show-full-url" : { + "comment" : "Option to show full URL in the address bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Full website address" + } + } + } + }, + "preferences.appearance.theme" : { + "comment" : "Theme preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Theme" + } + } + } + }, + "preferences.appearance.zoom" : { + "comment" : "Zoom settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoom" + } + } + } + }, + "preferences.appearance.zoom-picker" : { + "comment" : "Default page zoom picker title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Default page zoom" + } + } + } + }, + "preferences.autofill" : { + "comment" : "Show Autofill preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autofill" + } + } + } + }, + "preferences.default-browser" : { + "comment" : "Show default browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Default Browser" + } + } + } + }, + "preferences.default-browser.active" : { + "comment" : "Indicate that the browser is the default", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo is your default browser" + } + } + } + }, + "preferences.default-browser.button.make-default" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make DuckDuckGo Default…" + } + } + } + }, + "preferences.default-browser.inactive" : { + "comment" : "Indicate that the browser is not the default", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo is not your default browser." + } + } + } + }, + "preferences.downloads" : { + "comment" : "Show downloads browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + } + } + }, + "preferences.duck-player" : { + "comment" : "Show Duck Player browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Player" + } + } + } + }, + "preferences.general" : { + "comment" : "Show general preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General" + } + } + } + }, + "preferences.on-startup" : { + "comment" : "Name of the preferences section related to app startup", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "On Startup" + } + } + } + }, + "preferences.privacy" : { + "comment" : "Show privacy browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy" + } + } + } + }, + "preferences.reopen-windows" : { + "comment" : "Option to control session restoration", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reopen All Windows from Last Session" + } + } + } + }, + "preferences.show-home" : { + "comment" : "Option to control session startup", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open a new window" + } + } + } + }, + "preferences.subscription" : { + "comment" : "Show subscription preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Pro" + } + } + } + }, + "preferences.sync" : { + "comment" : "Show sync preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sync" + } + } + } + }, + "print.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Print…" + } + } + } + }, + "quit" : { + "comment" : "Quit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quit" + } + } + } + }, + "Quit DuckDuckGo" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Recently Closed" : { + "comment" : "Main Menu History item" + }, + "Redo" : { + "comment" : "Main Menu Edit item" + }, + "Reload Page" : { + "comment" : "Main Menu View item" + }, + "reload.page" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reload Page" + } + } + } + }, + "remove-favorite" : { + "comment" : "Remove Favorite button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Favorite" + } + } + } + }, + "remove.from.favorites" : { + "comment" : "Button for removing bookmarks from favorites", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove from Favorites" + } + } + } + }, + "reopen.last.closed.tab" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reopen Last Closed Tab" + } + } + } + }, + "reopen.last.closed.window" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reopen Last Closed Window" + } + } + } + }, + "restart.bitwarden" : { + "comment" : "Button to restart Bitwarden application", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart Bitwarden" + } + } + } + }, + "restart.bitwarden.info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden is not responding. Please restart it to initiate the communication again" + } + } + } + }, + "save" : { + "comment" : "Save button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "Save As…" : { + "comment" : "Main Menu File item" + }, + "save.image.as" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save Image As…" + } + } + } + }, + "scroll.to.find.app.settings" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Scroll to find the App Settings (All Accounts) section." + } + } + } + }, + "search.with.DuckDuckGo" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search with DuckDuckGo" + } + } + } + }, + "Select %@ Folder…" : { + + }, + "Select 1Password CSV File…" : { + + }, + "Select All" : { + "comment" : "Main Menu Edit item" + }, + "Select Bitwarden CSV File…" : { + + }, + "Select Bookmarks HTML File…" : { + + }, + "Select data to import:" : { + + }, + "Select LastPass CSV File…" : { + + }, + "Select Passwords CSV File…" : { + + }, + "Select Profile:" : { + + }, + "select.bitwarden.preferences" : { + "comment" : "Setup of the integration with Bitwarden app (up to and including macOS 12)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Bitwarden → Preferences from the Mac menu bar." + } + } + } + }, + "select.bitwarden.settings" : { + "comment" : "Setup of the integration with Bitwarden app (macOS 13 and above)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Bitwarden → Settings from the Mac menu bar." + } + } + } + }, + "Services" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "settings" : { + "comment" : "Menu item for opening settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "settings.hide-home-shortcut" : { + "comment" : "Settings Optionm to set Home Button visibility", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Home Button in Toolbar" + } + } + } + }, + "share.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Share" + } + } + } + }, + "share.menu.item.qr.code" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Create QR Code" + } + } + } + }, + "sharing.more" : { + "comment" : "Sharing Menu -> More…", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More…" + } + } + } + }, + "Show All" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Show Autofill Shortcut" : { + "comment" : "Main Menu View item" + }, + "Show Bookmarks Shortcut" : { + "comment" : "Main Menu View item" + }, + "Show Downloads Shortcut" : { + "comment" : "Main Menu View item" + }, + "Show left of the back button" : { + "comment" : "Main Menu > View > Home Button > left position item" + }, + "Show Next Tab" : { + "comment" : "Main Menu Window item" + }, + "Show Page Source" : { + "comment" : "Main Menu View-Developer item" + }, + "Show Previous Tab" : { + "comment" : "Main Menu Window item" + }, + "Show Resources" : { + "comment" : "Main Menu View-Developer item" + }, + "Show right of the reload button" : { + "comment" : "Main Menu > View > Home Button > right position item" + }, + "Show Spelling and Grammar" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Show Substitutions" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Copy/Paste" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Dashes" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Links" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Quotes" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Speech" : { + "comment" : "Main Menu Edit item" + }, + "Spelling and Grammar" : { + "comment" : "Main Menu Edit item" + }, + "Start Speaking" : { + "comment" : "Main Menu Edit-Speech item" + }, + "Stop" : { + "comment" : "Main Menu View item" + }, + "Stop Speaking" : { + "comment" : "Main Menu Edit-Speech item" + }, + "submit" : { + "comment" : "Submit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit" + } + } + } + }, + "submit.report" : { + "comment" : "Submit Report button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit Report" + } + } + } + }, + "subscription.menu.item" : { + "comment" : "Title for Subscription item in the options menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Pro" + } + } + } + }, + "Substitutions" : { + "comment" : "Main Menu Edit item" + }, + "tab.bookmarks.title" : { + "comment" : "Tab bookmarks title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "tab.dbp.title" : { + "comment" : "Tab data broker protection title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal" + } + } + } + }, + "tab.error.title" : { + "comment" : "Tab error title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Oops!" + } + } + } + }, + "tab.home.title" : { + "comment" : "Tab home title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab" + } + } + } + }, + "tab.onboarding.title" : { + "comment" : "Tab onboarding title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Welcome" + } + } + } + }, + "tab.preferences.title" : { + "comment" : "Tab preferences title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "Text Replacement" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "That didn’t work either. Please submit a report to help us fix the issue." : { + + }, + "The following information will be sent to DuckDuckGo. No personally identifiable information will be sent." : { + + }, + "The version of the browser you are trying to import from" : { + + }, + "tooltip.addToFavorites" : { + "comment" : "Tooltip for add to favorites button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to Favorites" + } + } + } + }, + "tooltip.application-menu.show" : { + "comment" : "Tooltip for the Application Menu button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open application menu" + } + } + } + }, + "tooltip.autofill.add-item" : { + "comment" : "Tooltip for the Add Item button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add item" + } + } + } + }, + "tooltip.autofill.more-options" : { + "comment" : "Tooltip for the More Options button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More options" + } + } + } + }, + "tooltip.autofill.shortcut" : { + "comment" : "Tooltip for the autofill shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autofill" + } + } + } + }, + "tooltip.bookmark.add" : { + "comment" : "Tooltip for the Add Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark this page" + } + } + } + }, + "tooltip.bookmark.edit" : { + "comment" : "Tooltip for the Edit Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit bookmark" + } + } + } + }, + "tooltip.bookmarks.manage-bookmarks" : { + "comment" : "Tooltip for the Manage Bookmarks button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage bookmarks" + } + } + } + }, + "tooltip.bookmarks.new-bookmark" : { + "comment" : "Tooltip for the New Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New bookmark" + } + } + } + }, + "tooltip.bookmarks.new-folder" : { + "comment" : "Tooltip for the New Folder button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New folder" + } + } + } + }, + "tooltip.bookmarks.shortcut" : { + "comment" : "Tooltip for the bookmarks shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "tooltip.clearHistory" : { + "comment" : "Tooltip for burn button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear browsing history for %@" + } + } + } + }, + "tooltip.clearHistoryAndData" : { + "comment" : "Tooltip for burn button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear browsing history and data for %@" + } + } + } + }, + "tooltip.downloads.clear-download-history" : { + "comment" : "Tooltip for the Clear Downloads button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear download history" + } + } + } + }, + "tooltip.downloads.open-downloads-folder" : { + "comment" : "Tooltip for the Open Downloads Folder button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open downloads folder" + } + } + } + }, + "tooltip.downloads.shortcut" : { + "comment" : "Tooltip for the downloads shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + } + } + }, + "tooltip.find-in-page.close" : { + "comment" : "Tooltip for the Find In Page bar's Close button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close find bar" + } + } + } + }, + "tooltip.find-in-page.next" : { + "comment" : "Tooltip for the Find In Page bar's Next button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next result" + } + } + } + }, + "tooltip.find-in-page.previous" : { + "comment" : "Tooltip for the Find In Page bar's Previous button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Previous result" + } + } + } + }, + "tooltip.fire.clear-browsing-history" : { + "comment" : "Tooltip for the Fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear browsing history" + } + } + } + }, + "tooltip.home.button" : { + "comment" : "Tooltip for the home button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Home" + } + } + } + }, + "tooltip.navigation.back" : { + "comment" : "Tooltip for the Back button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the previous page\nHold to show history" + } + } + } + }, + "tooltip.navigation.forward" : { + "comment" : "Tooltip for the Forward button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the next page\nHold to show history" + } + } + } + }, + "tooltip.navigation.refresh" : { + "comment" : "Tooltip for the Refresh button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reload this page" + } + } + } + }, + "tooltip.navigation.stop" : { + "comment" : "Tooltip for the Stop Navigation button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Stop loading this page" + } + } + } + }, + "tooltip.privacy-dashboard.show" : { + "comment" : "Tooltip for the Privacy Dashboard button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the Privacy Dashboard and manage site settings" + } + } + } + }, + "tooltip.tab.new-tab" : { + "comment" : "Tooltip for the New Tab button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open a new tab" + } + } + } + }, + "Transformations" : { + "comment" : "Main Menu Edit item" + }, + "Undo" : { + "comment" : "Main Menu Edit item" + }, + "unpin.tab" : { + "comment" : "Menu item. Unpin as a verb", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unpin Tab" + } + } + } + }, + "unsupported.device.info.alert.header" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your version of macOS is no longer supported." + } + } + } + }, + "version" : { + "comment" : "Displays the version and build numbers", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Version %1$@ (%2$@)" + } + } + } + }, + "View" : { + "comment" : "Main Menu View" + }, + "We were unable to import directly from %@." : { + + }, + "web.tracking.protection.explenation" : { + "comment" : "feature explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo automatically blocks hidden trackers as you browse the web." + } + } + } + }, + "web.tracking.protection.title" : { + "comment" : "Web tracking protection settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Web Tracking Protection" + } + } + } + }, + "Window" : { + "comment" : "Main Menu " + }, + "You'll be asked to enter your Primary Password for %@.\n\nImported passwords are encrypted and only stored on this computer." : { + + }, + "zoom" : { + "comment" : "Menu with Zooming commands", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoom" + } + } + } + }, + "Zoom In" : { + "comment" : "Main Menu View item" + }, + "Zoom Out" : { + "comment" : "Main Menu View item" + } + }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/UnitTests/DataImport/InstructionsFormatParserTests.swift b/UnitTests/DataImport/InstructionsFormatParserTests.swift index 18d934d541..55da6b9be4 100644 --- a/UnitTests/DataImport/InstructionsFormatParserTests.swift +++ b/UnitTests/DataImport/InstructionsFormatParserTests.swift @@ -17,3 +17,99 @@ // import Foundation +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class InstructionsFormatParserTests: XCTestCase { + + func testOnlyTextLines() throws { + let format = """ + line 1 + line 2 + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed, [[.text("line 1")], [.text("line 2")]]) + print(parsed) + } + + func testLinesWithEscapedExpressions() throws { + let format = """ + %d line %s %@ + line %ld + %lld%s%@ line 3 %d + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed, [ + [.number, .text(" line "), .string(), .text(" "), .object], + [.text("line "), .number], + [.number, .string(), .object, .text(" line 3 "), .number], + ]) + print(parsed) + } + + func testItalicMarkdown() throws { + let format = "st_art_ some_text “_italic_text_” text_ __italic_text__ text" + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [[.text("st_art_ some_text “"), .text("italic_text", italic: true), .text("” text_ "), .text("italic_text", italic: true), .text(" text")]]) + } + + func testItalicMarkdownMultiline() throws { + let format = """ + some_text __italic_text_ + text_ __non_italic_text__ text + """ + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [ + [.text("some_text "), .text("italic_text_", italic: true)], + [.text("text_ ", italic: true), .text("non_italic_text__ text", italic: false)]]) + } + + func testBoldMarkdown() throws { + let format = "st*art* some*text *nonbold*text* text* **bold*text** text" + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [[.text("st*art* some*text *nonbold*text* text* "), .text("bold*text", bold: true), .text(" text")]]) + } + + func testBoldMarkdownMultiline() throws { + let format = """ + *some*text* **bold*text* + text* **non*bold*text** text **.csv** + """ + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [ + [.text("*some*text* "), .text("bold*text*", bold: true)], + [.text("text* ", bold: true), .text("non*bold*text** text", bold: false), .text(".csv", bold: true)] + ]) + } + + func testMarkdownWithExpressions() throws { + let format = """ + %d Open **%s** + %d In a fresh tab, click %@ then **Google __Password__ Manager → Settings ** + %d Find “Export _Passwords_” and click **Download File** + %d __Save the* passwords **file** someplace__ you can_find it (e.g. Desktop) + %d %@ + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed[safe: 0], [.number, .text(" Open "), .string(bold: true)]) + XCTAssertEqual(parsed[safe: 1], [.number, .text(" In a fresh tab, click "), .object, .text(" then "), .text("Google ", bold: true), .text("Password", bold: true, italic: true), .text(" Manager → Settings ", bold: true)]) + XCTAssertEqual(parsed[safe: 2], [.number, .text(" Find “Export "), .text("Passwords", italic: true), .text("” and click "), .text("Download File", bold: true)]) + XCTAssertEqual(parsed[safe: 3], [.number, .text(" "), .text("Save the* passwords ", italic: true), .text("file", bold: true, italic: true), .text(" someplace", italic: true), .text(" you can_find it (e.g. Desktop)")]) + XCTAssertEqual(parsed[safe: 4], [.number, .text(" "), .object]) + XCTAssertNil(parsed[safe: 5]) + } + + func testInvalidEscapeSequencesThrow() { + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "text with %z escape char")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%llld text with invalid digit")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%2d unsupported format")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%.2d unsupported format")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%f unsupported format")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "unexpected eof %")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "unexpected eol %\nasdf")) + } + +} From 15418c416bffd69391a7d399c91ed379d000f5e7 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 27 Nov 2023 13:55:30 +0600 Subject: [PATCH 37/83] add comments, naming adjustment, add suppot for custom localized arguments positioning --- DuckDuckGo.xcodeproj/project.pbxproj | 22 ++-- ...r.swift => InstructionsFormatParser.swift} | 80 +++++++++--- .../DataImport/View/FileImportView.swift | 115 +++++++++++------- .../InstructionsFormatParserTests.swift | 36 ++++-- 4 files changed, 169 insertions(+), 84 deletions(-) rename DuckDuckGo/DataImport/Model/{InstructionsParser.swift => InstructionsFormatParser.swift} (75%) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5c810c2f00..5672de49de 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -3404,10 +3404,10 @@ B65E6BA026D9F10600095F96 /* NSBezierPathExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */; }; B6619EF62B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */; }; B6619EF72B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */; }; - B6619EFB2B111CC500CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; - B6619EFC2B111CC600CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; - B6619EFD2B111CCA00CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; - B6619EFE2B111CCC00CD9186 /* InstructionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */; }; + B6619EFB2B111CC500CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; + B6619EFC2B111CC600CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; + B6619EFD2B111CCA00CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; + B6619EFE2B111CCC00CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; B66260DD29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260DE29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260E029AC6EBD00E9E3EE /* HistoryTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */; }; @@ -4594,8 +4594,6 @@ AA80EC68256C4691007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/BrowserTab.storyboard; sourceTree = ""; }; AA80EC74256C46A2007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Suggestion.storyboard; sourceTree = ""; }; AA80EC7A256C46AA007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/TabBar.storyboard; sourceTree = ""; }; - AA80EC8A256C49B8007083E7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - AA80EC90256C49BC007083E7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; AA840A9727319D1600E63CDD /* FirePopoverWrapperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirePopoverWrapperViewController.swift; sourceTree = ""; }; AA88D14A252A557100980B4E /* URLRequestExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestExtension.swift; sourceTree = ""; }; AA8EDF2324923E980071C2E8 /* URLExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = ""; }; @@ -4781,7 +4779,7 @@ B65E6B9D26D9EC0800095F96 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSBezierPathExtension.swift; sourceTree = ""; }; B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsFormatParserTests.swift; sourceTree = ""; }; - B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsParser.swift; sourceTree = ""; }; + B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsFormatParser.swift; sourceTree = ""; }; B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationProtectionTabExtension.swift; sourceTree = ""; }; B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryTabExtension.swift; sourceTree = ""; }; B66260E529ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHotkeyHandler.swift; sourceTree = ""; }; @@ -8500,7 +8498,7 @@ B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */, B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */, B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */, - B6619EF82B111CBE00CD9186 /* InstructionsParser.swift */, + B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */, ); path = Model; sourceTree = ""; @@ -10191,7 +10189,7 @@ 3192A00D2A4C4CFF0084EA89 /* ChromiumDataImporter.swift in Sources */, 3192A00F2A4C4CFF0084EA89 /* WKBackForwardListItemViewModel.swift in Sources */, 1D76760E2A9CE4F000DA0BD7 /* SupportedOsChecker.swift in Sources */, - B6619EFD2B111CCA00CD9186 /* InstructionsParser.swift in Sources */, + B6619EFD2B111CCA00CD9186 /* InstructionsFormatParser.swift in Sources */, 3192A0102A4C4CFF0084EA89 /* BWNotRespondingAlert.swift in Sources */, 3192A0112A4C4CFF0084EA89 /* DebugUserScript.swift in Sources */, 3192A0122A4C4CFF0084EA89 /* RecentlyClosedTab.swift in Sources */, @@ -11242,7 +11240,7 @@ 3706FC06293F65D500E42796 /* OnboardingViewModel.swift in Sources */, 3706FC07293F65D500E42796 /* ScriptSourceProviding.swift in Sources */, 4B6785402AA7C726008A5004 /* DailyPixel.swift in Sources */, - B6619EFC2B111CC600CD9186 /* InstructionsParser.swift in Sources */, + B6619EFC2B111CC600CD9186 /* InstructionsFormatParser.swift in Sources */, 3706FC08293F65D500E42796 /* CoreDataBookmarkImporter.swift in Sources */, 3706FC09293F65D500E42796 /* SuggestionViewModel.swift in Sources */, 3706FC0A293F65D500E42796 /* BookmarkManagedObject.swift in Sources */, @@ -12381,7 +12379,7 @@ 4B957B452AC7AE700062CA31 /* BookmarkHTMLReader.swift in Sources */, 4B957B462AC7AE700062CA31 /* Tab+NSSecureCoding.swift in Sources */, 4B957B472AC7AE700062CA31 /* NSNotificationName+EmailManager.swift in Sources */, - B6619EFE2B111CCC00CD9186 /* InstructionsParser.swift in Sources */, + B6619EFE2B111CCC00CD9186 /* InstructionsFormatParser.swift in Sources */, 4B957B482AC7AE700062CA31 /* MouseOverButton.swift in Sources */, 4B957B492AC7AE700062CA31 /* FireInfoViewController.swift in Sources */, 4B957B4A2AC7AE700062CA31 /* LoginItem+NetworkProtection.swift in Sources */, @@ -12633,7 +12631,7 @@ 1D43EB3A292B63B00065E5D6 /* BWRequest.swift in Sources */, B68458CD25C7EB9000DC17B6 /* WKWebViewConfigurationExtensions.swift in Sources */, 85AC7ADD27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift in Sources */, - B6619EFB2B111CC500CD9186 /* InstructionsParser.swift in Sources */, + B6619EFB2B111CC500CD9186 /* InstructionsFormatParser.swift in Sources */, AAC30A26268DFEE200D2D9CD /* CrashReporter.swift in Sources */, B60D64492AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */, 3184AC6D288F29D800C35E4B /* BadgeNotificationAnimationModel.swift in Sources */, diff --git a/DuckDuckGo/DataImport/Model/InstructionsParser.swift b/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift similarity index 75% rename from DuckDuckGo/DataImport/Model/InstructionsParser.swift rename to DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift index e42369eac2..4c20940d51 100644 --- a/DuckDuckGo/DataImport/Model/InstructionsParser.swift +++ b/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift @@ -1,5 +1,5 @@ // -// InstructionsParser.swift +// InstructionsFormatParser.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -19,13 +19,19 @@ import Common import Foundation +/// NSLocalizedString format parser for CSV/HTML data import instructions screen struct InstructionsFormatParser { + // NSLocalizedString("Formatted text %s %d %@") enum FormatComponent: Equatable { + // String literals: "Formatted text ", " ", " " case text(String, bold: Bool = false, italic: Bool = false) - case number - case string(bold: Bool = false, italic: Bool = false) - case object + // %d + case number(argIndex: Int) + // %s + case string(argIndex: Int, bold: Bool = false, italic: Bool = false) + // %@: represents inline image or button + case object(argIndex: Int) var isNumber: Bool { if case .number = self { true } else { false } @@ -61,16 +67,13 @@ struct InstructionsFormatParser { func parse(format: String) throws -> [[FormatComponent]] { var parser = Parser() - // TODO: pull this into parser instead - let format = format.replacing(regex("(%)\\d+\\$(\\S)"), with: "$1$2") - var idx: Int! do { for (index, character) in format.enumerated() { idx = index try parser.accept(character) } - + // eof try parser.accept(nil) } catch let errorType as ParseError.ErrorType { throw ParseError(type: errorType, format: format, position: idx + 1, @@ -81,14 +84,36 @@ struct InstructionsFormatParser { } private struct Parser { - var delimiter: Character? - var result: [[FormatComponent]] = [[]] + // currently collected .text literal var currentLiteral = "" - var currentEscapeSequence = "" - var isBold = false + // currently collected escape syntax: %1$, **, __ + var currentEscapeSequence = "" { + didSet { + currentArgIndex = 0 + } + } + // when a %-escaped component contains argument number: %12$s + var currentArgIndex: Int = 0 + func countCurrentArgIndex() -> Int { + var count: Int = 0 + for line in result { + for component in line { + switch component { + case .text: continue + case .number, .string, .object: + count += 1 + } + } + } + return count + 1 + } + + // __italic__ = 2, _italic_ = 1, non-italic = 0 var isItalic: Int = 0 + // **bold** + var isBold = false @inline(__always) mutating func append(_ character: Character) { currentLiteral.append(character) @@ -120,20 +145,34 @@ struct InstructionsFormatParser { // swiftlint:disable:next cyclomatic_complexity function_body_length mutating func accept(_ character: Character?) throws { switch (currentEscapeSequence, character) { + // beggining of %-escaped seq case ("", "%"): currentEscapeSequence.append("%") + // argument index in %-escaped seq: %12$s + case ("%", .some(let character)) where character.isNumber: + // append digit to arg index + currentArgIndex = currentArgIndex * 10 + (Int(String(character)) ?? 0) + + // arg index ended with $ + case ("%", "$"): + break // ignore + + // %s arg case ("%", "s"): - append(.string(bold: isBold, italic: isItalic > 0)) + append(.string(argIndex: currentArgIndex > 0 ? currentArgIndex : countCurrentArgIndex(), bold: isBold, italic: isItalic > 0)) + // %@ arg case ("%", "@"): - append(.object) + append(.object(argIndex: currentArgIndex > 0 ? currentArgIndex : countCurrentArgIndex())) + // %d %ld %lld args case ("%", "l"), ("%l", "l"): currentEscapeSequence.append("l") case ("%", "d"), ("%l", "d"), ("%ll", "d"): - append(.number) + append(.number(argIndex: currentArgIndex > 0 ? currentArgIndex : countCurrentArgIndex())) + // %% escaped % char case ("%", "%"): currentEscapeSequence = "" append("%") @@ -151,7 +190,7 @@ struct InstructionsFormatParser { append("*") try accept(character) - // " " follows ** - reset and recurse + // " " follows ** - reset and recurse case ("**", .some(let character)) where !character.isWordChar && !isBold: append("*") append("*") @@ -166,12 +205,12 @@ struct InstructionsFormatParser { ("_", "_"): currentEscapeSequence.append("_") - // one "_" followed by non-alphanumeric – reset and recurse + // one "_" followed by non-alphanumeric – reset and recurse case ("_", .some(let character)) where !character.isWordChar && isItalic == 0: append("_") try accept(character) - // one "_" followed by non-alphanumeric when italic == 1: toggle italic + // one "_" followed by non-alphanumeric when italic == 1: toggle italic case ("_", .some(let character)) where !character.isWordChar && isItalic == 1: flushField() isItalic = 0 @@ -183,11 +222,12 @@ struct InstructionsFormatParser { isItalic = 1 try accept(character) - case ("_", _): // word continues after dash + // word continues after dash + case ("_", _): append("_") try accept(character) - // " " follows __ - reset and recurse + // " " follows __ - reset and recurse case ("__", .some(let character)) where !character.isWordChar && isItalic == 0: append("_") append("_") diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index b6c0a5ef20..f664415e3c 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -365,19 +365,32 @@ struct FileImportView: View { struct InstructionsView: View { - enum TextPart { + // item used in InstructionBuilder: string literal, NSImage or Choose File Button (AnyView) + enum InstructionsItem { + case string(String) + case image(NSImage) + case view(AnyView) + } + // Text item view ViewModel - joined in a line using Text(string).bold().italic() + Text(image).. seq + enum TextItem { case image(NSImage) case text(text: String, isBold: Bool, isItalic: Bool) } + // Possible InstructionsView line components: + // - lineNumber (number in a circle) + // - textItems: Text(string).bold().italic() + Text(image).. seq + // - view: Choose File Button enum InstructionsViewItem { case lineNumber(Int) - case textParts([TextPart]) + case textItems([TextItem]) case view(AnyView) } + // Text font - used to calculate inline image baseline offset and set Text/Font modifier let fontName: String let fontSize: CGFloat + // View Model private let instructions: [[InstructionsViewItem]] // swiftlint:disable:next function_body_length cyclomatic_complexity @@ -385,75 +398,99 @@ struct InstructionsView: View { self.fontName = fontName self.fontSize = fontSize - let items = builder() + var args = builder() - guard case .string(let format) = items.first else { + guard case .string(let format) = args.first else { assertionFailure("First item should provide instructions format using NSLocalizedString") self.instructions = [] return } do { - + // parse %12$d, %23$s, %34$@ out of the localized format into component sequence let formatLines = try InstructionsFormatParser().parse(format: format) - var result = [[InstructionsViewItem]]() - var argIndex = 1 - var lineNumber = 1 - + // assertion helper func fline(_ lineIdx: Int) -> String { format.components(separatedBy: "\n")[safe: lineIdx] ?? "?" } + // arguments are positioned (%42$s %23$@) but lines numbers are auto-incremented + // but the line arguments (%12$d) are still indexed. + // insert fake components at .line components positions to keep order + let lineNumberArgumentIndices = formatLines.reduce(into: IndexSet()) { + $0.formUnion($1.reduce(into: IndexSet()) { + if case .number(argIndex: let argIndex) = $1 { + $0.insert(argIndex) + } + }) + } + for idx in lineNumberArgumentIndices { + args.insert(.string(""), at: idx) + } + + // generate instructions view model from localized format + var result = [[InstructionsViewItem]]() + var lineNumber = 1 + var usedArgs = IndexSet() for (lineIdx, line) in formatLines.enumerated() { + // collect view items placed in line var resultLine = [InstructionsViewItem]() - func appendTextPart(_ textPart: TextPart) { - if case .textParts(var parts) = resultLine.last { - parts.append(textPart) - resultLine[resultLine.endIndex - 1] = .textParts(parts) + func appendTextItem(_ textItem: TextItem) { + // text item should be appended to an ongoing textItem sequence if present + if case .textItems(var items) = resultLine.last { + items.append(textItem) + resultLine[resultLine.endIndex - 1] = .textItems(items) } else { - resultLine.append(.textParts([textPart])) + // previous item is not .textItems - initiate a new textItem sequence + resultLine.append(.textItems([textItem])) } } for component in line { switch component { - case .number: + // %d line number argument + case .number(let argIndex): resultLine.append(.lineNumber(lineNumber)) - lineNumber += 1 + usedArgs.insert(argIndex) + lineNumber += 1 // line number is auto-incremented + + // text literal [optionally with markdown attributes] case .text(let text, bold: let bold, italic: let italic): - appendTextPart(.text(text: text, isBold: bold, isItalic: italic)) - case .string(bold: let bold, italic: let italic): - switch items[safe: argIndex] { + appendTextItem(.text(text: text, isBold: bold, isItalic: italic)) + + // %s string argument + case .string(let argIndex, bold: let bold, italic: let italic): + switch args[safe: argIndex] { case .string(let str): - appendTextPart(.text(text: str, isBold: bold, isItalic: italic)) + appendTextItem(.text(text: str, isBold: bold, isItalic: italic)) case .none: - assertionFailure("String argument missing at index \(argIndex) in “\(fline(lineIdx))”") + assertionFailure("String argument missing at index \(argIndex) in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") case .image(let obj as Any), .view(let obj as Any): - assertionFailure("Unexpected object argument “\(obj)”, expected string at index \(argIndex) in “\(fline(lineIdx))”") + assertionFailure("Unexpected object argument at index \(argIndex):\n\(obj)\nExpected object in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") } - argIndex += 1 + usedArgs.insert(argIndex) - case .object: - switch items[safe: argIndex] { + // %@ object argument - inline image or button (view) + case .object(let argIndex): + switch args[safe: argIndex] { case .image(let image): - appendTextPart(.image(image)) + appendTextItem(.image(image)) case .view(let view): resultLine.append(.view(view)) case .none: - assertionFailure("Object argument missing at index \(argIndex) in “\(fline(lineIdx))”") + assertionFailure("Object argument missing at index \(argIndex) in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") case .string(let string): - assertionFailure("Unexpected string argument “\(string)”, expected object at index \(argIndex) in “\(fline(lineIdx))”") + assertionFailure("Unexpected string argument at index \(argIndex):\n“\(string)”.\nExpected object in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") } - argIndex += 1 + usedArgs.insert(argIndex) } } result.append(resultLine) } - if argIndex < items.count { - assertionFailure("Argument \(items[argIndex]) not used anywhere") - } + assert(usedArgs.subtracting(IndexSet(args.indices)).isEmpty, + "Unused arguments at indices \(usedArgs.subtracting(IndexSet(args.indices)))") self.instructions = result @@ -463,12 +500,6 @@ struct InstructionsView: View { } } - enum InstructionsItem { - case string(String) - case image(NSImage) - case view(AnyView) - } - @resultBuilder struct InstructionsBuilder { static func buildBlock(_ components: [InstructionsItem]...) -> [InstructionsItem] { @@ -514,7 +545,6 @@ struct InstructionsView: View { static func buildExpression(_ expression: Void) -> [InstructionsItem] { return [] } - } var body: some View { @@ -524,7 +554,7 @@ struct InstructionsView: View { switch instructions[i][j] { case .lineNumber(let number): CircleNumberView(number: number) - case .textParts(let textParts): + case .textItems(let textParts): Text(textParts, fontName: fontName, fontSize: fontSize) case .view(let view): view @@ -538,11 +568,12 @@ struct InstructionsView: View { private extension Text { - init(_ textPart: InstructionsView.TextPart, fontName: String, fontSize: CGFloat) { + init(_ textPart: InstructionsView.TextItem, fontName: String, fontSize: CGFloat) { switch textPart { case .image(let image): self.init(Image(nsImage: image)) self = self.baselineOffset(fontSize - image.size.height) + case .text(let text, let isBold, let isItalic): self.init(text) self = self.font(.custom(fontName, size: fontSize)) @@ -555,7 +586,7 @@ private extension Text { } } - init(_ textParts: [InstructionsView.TextPart], fontName: String, fontSize: CGFloat) { + init(_ textParts: [InstructionsView.TextItem], fontName: String, fontSize: CGFloat) { guard !textParts.isEmpty else { assertionFailure("Empty TextParts") self.init("") diff --git a/UnitTests/DataImport/InstructionsFormatParserTests.swift b/UnitTests/DataImport/InstructionsFormatParserTests.swift index 55da6b9be4..7ea0c24e0c 100644 --- a/UnitTests/DataImport/InstructionsFormatParserTests.swift +++ b/UnitTests/DataImport/InstructionsFormatParserTests.swift @@ -42,9 +42,25 @@ final class InstructionsFormatParserTests: XCTestCase { let parsed = try InstructionsFormatParser().parse(format: format) XCTAssertEqual(parsed, [ - [.number, .text(" line "), .string(), .text(" "), .object], - [.text("line "), .number], - [.number, .string(), .object, .text(" line 3 "), .number], + [.number(argIndex: 1), .text("line "), .string(argIndex: 2), .text(" "), .object(argIndex: 3)], + [.text("line "), .number(argIndex: 4)], + [.number(argIndex: 5), .string(argIndex: 6), .object(argIndex: 7), .text(" line 3 "), .number(argIndex: 8)], + ]) + print(parsed) + } + + func testLinesWithIndexedEscapedExpressions() throws { + let format = """ + %2$d line %3$s %1$@ + line %ld + %lld%7$s%6$@ line 3 %8$d + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed, [ + [.number(argIndex: 2), .text("line "), .string(argIndex: 3), .text(" "), .object(argIndex: 1)], + [.text("line "), .number(argIndex: 4)], + [.number(argIndex: 5), .string(argIndex: 7), .object(argIndex: 6), .text(" line 3 "), .number(argIndex: 8)], ]) print(parsed) } @@ -80,7 +96,7 @@ final class InstructionsFormatParserTests: XCTestCase { let parsed = try InstructionsFormatParser().parse(format: format) XCTAssertEqual(parsed, [ [.text("*some*text* "), .text("bold*text*", bold: true)], - [.text("text* ", bold: true), .text("non*bold*text** text", bold: false), .text(".csv", bold: true)] + [.text("text* ", bold: true), .text("non*bold*text** text ", bold: false), .text(".csv", bold: true)] ]) } @@ -94,18 +110,18 @@ final class InstructionsFormatParserTests: XCTestCase { """ let parsed = try InstructionsFormatParser().parse(format: format) - XCTAssertEqual(parsed[safe: 0], [.number, .text(" Open "), .string(bold: true)]) - XCTAssertEqual(parsed[safe: 1], [.number, .text(" In a fresh tab, click "), .object, .text(" then "), .text("Google ", bold: true), .text("Password", bold: true, italic: true), .text(" Manager → Settings ", bold: true)]) - XCTAssertEqual(parsed[safe: 2], [.number, .text(" Find “Export "), .text("Passwords", italic: true), .text("” and click "), .text("Download File", bold: true)]) - XCTAssertEqual(parsed[safe: 3], [.number, .text(" "), .text("Save the* passwords ", italic: true), .text("file", bold: true, italic: true), .text(" someplace", italic: true), .text(" you can_find it (e.g. Desktop)")]) - XCTAssertEqual(parsed[safe: 4], [.number, .text(" "), .object]) + XCTAssertEqual(parsed[safe: 0], [.number(argIndex: 1), .text("Open "), .string(argIndex: 2, bold: true)]) + XCTAssertEqual(parsed[safe: 1], [.number(argIndex: 3), .text("In a fresh tab, click "), .object(argIndex: 4), .text(" then "), .text("Google ", bold: true), .text("Password", bold: true, italic: true), .text(" Manager → Settings ", bold: true)]) + XCTAssertEqual(parsed[safe: 2], [.number(argIndex: 5), .text("Find “Export "), .text("Passwords", italic: true), .text("” and click "), .text("Download File", bold: true)]) + XCTAssertEqual(parsed[safe: 3], [.number(argIndex: 6), .text("Save the* passwords ", italic: true), .text("file", bold: true, italic: true), .text(" someplace", italic: true), .text(" you can_find it (e.g. Desktop)")]) + XCTAssertEqual(parsed[safe: 4], [.number(argIndex: 7), .object(argIndex: 8)]) XCTAssertNil(parsed[safe: 5]) } func testInvalidEscapeSequencesThrow() { XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "text with %z escape char")) XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%llld text with invalid digit")) - XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%2d unsupported format")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%.2f unsupported format")) XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%.2d unsupported format")) XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%f unsupported format")) XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "unexpected eof %")) From b44591987694d412b858e147f488d1beed710554 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 27 Nov 2023 14:30:36 +0600 Subject: [PATCH 38/83] add localization comments --- DuckDuckGo/Common/Localizables/UserText.swift | 2 ++ .../Model/InstructionsFormatParser.swift | 2 +- .../View/BrowserImportMoreInfoView.swift | 4 ++-- .../View/DataImportProfilePicker.swift | 2 +- .../DataImport/View/DataImportSummaryView.swift | 15 ++++++++++----- .../DataImport/View/DataImportTypePicker.swift | 6 ++++-- DuckDuckGo/DataImport/View/DataImportView.swift | 14 +++++++++----- .../DataImport/View/ReportFeedbackView.swift | 17 +++++++++++++---- .../View/RequestFilePermissionView.swift | 3 ++- 9 files changed, 44 insertions(+), 21 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 20da91b520..53ecb62650 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -347,6 +347,8 @@ struct UserText { static let importBookmarks = NSLocalizedString("import.browser.data.bookmarks", value: "Import Bookmarks…", comment: "Opens Import Browser Data dialog") static let importPasswords = NSLocalizedString("import.browser.data.passwords", value: "Import Passwords…", comment: "Opens Import Browser Data dialog") + static let importDataTitle = NSLocalizedString("import.browser.data", value: "Import Browser Data", comment: "Import Browser Data dialog title") + static let exportLogins = NSLocalizedString("export.logins.data", value: "Export Passwords…", comment: "Opens Export Logins Data dialog") static let exportBookmarks = NSLocalizedString("export.bookmarks.menu.item", value: "Export Bookmarks…", comment: "Export bookmarks menu item") static let bookmarks = NSLocalizedString("bookmarks", value: "Bookmarks", comment: "Button for bookmarks") diff --git a/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift b/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift index 4c20940d51..0f6fdbd576 100644 --- a/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift +++ b/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift @@ -22,7 +22,7 @@ import Foundation /// NSLocalizedString format parser for CSV/HTML data import instructions screen struct InstructionsFormatParser { - // NSLocalizedString("Formatted text %s %d %@") + // Localized String("Formatted text %s %d %@") enum FormatComponent: Equatable { // String literals: "Formatted text ", " ", " " case text(String, bold: Bool = false, italic: Bool = false) diff --git a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoView.swift b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoView.swift index e3619fe62e..24806677bb 100644 --- a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoView.swift +++ b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoView.swift @@ -33,14 +33,14 @@ struct BrowserImportMoreInfoView: View { If your computer prompts you to enter a password prior to import, DuckDuckGo will not see that password. Imported passwords are stored securely using encryption. - """) + """, comment: "Warning that Chromium data import would require entering system passwords.") case .firefox: Text(""" You'll be asked to enter your Primary Password for \(source.importSourceName). Imported passwords are encrypted and only stored on this computer. - """) + """, comment: "Warning that Firefox-based browser name (%@) data import would require entering a Primary Password for the browser.") case .safari, .safariTechnologyPreview, .yandex, .csv, .bitwarden, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML, .tor: fatalError("Unsupported source for more info") diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index c8b7cdb973..958f2e88f7 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -31,7 +31,7 @@ struct DataImportProfilePicker: View { var body: some View { VStack(alignment: .leading, spacing: 10) { if profiles.count > 1 { - Text("Select Profile:") + Text("Select Profile:", comment: "Browser Profile picker title for Data Import") .font(.headline) Picker(selection: Binding { diff --git a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift index 443279f762..59775ae5bb 100644 --- a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift +++ b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift @@ -39,30 +39,35 @@ struct DataImportSummaryView: View { case .bookmarks: HStack { successImage() - Text("Bookmarks: \(item.result.successful)") + Text("Bookmarks: \(item.result.successful)", + comment: "Data import summary format of how many bookmarks (%lld) were successfully imported.") } if item.result.duplicate > 0 { HStack { failureImage() - Text("Duplicate Bookmarks Skipped: \(item.result.duplicate)") + Text("Duplicate Bookmarks Skipped: \(item.result.duplicate)", + comment: "Data import summary format of how many duplicate bookmarks (%lld) were skipped during import.") } } if item.result.failed > 0 { HStack { failureImage() - Text("Bookmark import failed: \(item.result.failed)") + Text("Bookmark import failed: \(item.result.failed)", + comment: "Data import summary format of how many bookmarks (%lld) failed to import.") } } case .passwords: HStack { successImage() - Text("Passwords: \(item.result.successful)") + Text("Passwords: \(item.result.successful)", + comment: "Data import summary format of how many passwords (%lld) were successfully imported.") } if item.result.failed > 0 { HStack { failureImage() - Text("Login import failed: \(item.result.failed)") + Text("Passwords import failed: \(item.result.failed)", + comment: "Data import summary format of how many passwords (%lld) failed to import.") } } } diff --git a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift index 1b368e3cb5..b82b981915 100644 --- a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift @@ -28,7 +28,8 @@ struct DataImportTypePicker: View { var body: some View { VStack(alignment: .leading) { - Text("Select data to import:") + Text("Select data to import:", + comment: "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks.") .font(.headline) ForEach(DataImport.DataType.allCases, id: \.self) { dataType in @@ -48,7 +49,8 @@ struct DataImportTypePicker: View { // subtitle if !viewModel.importSource.supportedDataTypes.contains(dataType) { - Text("\(viewModel.importSource.importSourceName) does not support storing \(dataType.displayName)") + Text("\(viewModel.importSource.importSourceName) does not support storing \(dataType.displayName)", + comment: "Data Import disabled checkbox message about a browser (%1$@) not supporting storing a data type (%2$@ - Bookmarks or Passwords)") .foregroundColor(Color(.disabledControlTextColor)) } } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 0c2d48f760..741abbf5ed 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -75,7 +75,7 @@ struct DataImportView: View { private func viewHeader() -> some View { VStack(alignment: .leading, spacing: 0) { - Text("Import Browser Data") + Text(UserText.importDataTitle) .font(.headline) .padding(.bottom, 16) @@ -119,10 +119,12 @@ struct DataImportView: View { case .fileImport(let dataType): // if browser importer failed - display error message if viewModel.hasDataTypeImportFailed(dataType) { - Text("We were unable to import directly from \(viewModel.importSource.importSourceName).") + Text("We were unable to import directly from \(viewModel.importSource.importSourceName).", + comment: "Message when data import fails from a browser. %@ - a browser name") .font(.headline) Spacer().frame(height: 8) - Text("Let’s try doing it manually. It won’t take long.") + Text("Let’s try doing it manually. It won’t take long.", + comment: "Suggestion to switch to a Manual File Data Import when data import fails.") Spacer().frame(height: 24) } @@ -135,7 +137,8 @@ struct DataImportView: View { case .fileImportSummary(let dataType): // present file impoter import summary for one data type - Text("\(dataType.displayName) Import Complete") + Text("\(dataType.displayName) Import Complete", + comment: "Passwords or Bookmarks (%@) File Data Import completion message") .font(.headline) Spacer().frame(height: 12) DataImportSummaryView(summary: ( @@ -146,7 +149,8 @@ struct DataImportView: View { case .summary: // total import summary - Text("Import Complete") + Text("Import Complete", + comment: "Browser data import completion message") .font(.headline) Spacer().frame(height: 12) diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift index 4c33e68c36..92ede55ec5 100644 --- a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -33,14 +33,22 @@ struct ReportFeedbackView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Text(title) - .font(.headline) + { + if model.retryNumber <= 1 { + Text("Please submit a report to help us fix the issue.", + comment: "Data import failure Report dialog title.") + } else { + Text("That didn’t work either. Please submit a report to help us fix the issue.", + comment: "Data import failure Report dialog title containing a message that not only automatic data import has failed failed but manual browser data import didn‘t work either.") + } + }().font(.headline) + Spacer().frame(height: 8) VStack(alignment: .leading, spacing: 12) { Text(""" The following information will be sent to DuckDuckGo. No personally identifiable information will be sent. - """) + """, comment: "Data import failure Report dialog subtitle about the data being collected with the report.") InfoItemView("macOS version", model.osVersion) InfoItemView("DuckDuckGo browser version", model.appVersion) @@ -59,7 +67,8 @@ struct ReportFeedbackView: View { if model.text.isEmpty { HStack { - Text("Add any details that you think may help us fix the problem") + Text("Add any details that you think may help us fix the problem", + comment: "Data import failure Report dialog suggestion to provide a comments with extra details helping to identify the data import problem.") .font(.custom("SF Pro Text", size: 13)) .foregroundColor(Color(.placeholderTextColor)) Spacer() diff --git a/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift b/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift index 884419d31a..17549183d4 100644 --- a/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift +++ b/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift @@ -34,7 +34,8 @@ struct RequestFilePermissionView: View { var body: some View { VStack(alignment: .leading, spacing: 20) { - Text("DuckDuckGo needs your permission to read the \(source.importSourceName) bookmarks file. Select the \(source.importSourceName) folder to import bookmarks.") + Text("DuckDuckGo needs your permission to read the \(source.importSourceName) bookmarks file. Select the \(source.importSourceName) folder to import bookmarks.", + comment: "Data import warning that DuckDuckGo browser requires file reading permissions for another browser name (%1$@), and instruction to select its (same browser name - %2$@) bookmarks folder.") Button("Select \(source.importSourceName) Folder…") { if let url = requestDataDirectoryPermission(url) { callback(url) From 245bbe60d4277222f4240d2931192f46bd23c262 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 5 Dec 2023 01:02:20 +0600 Subject: [PATCH 39/83] mostly there; tests in progress --- DuckDuckGo.xcodeproj/project.pbxproj | 40 ++ ...kDuckGo Privacy Browser App Store.xcscheme | 3 +- DuckDuckGo/Common/Localizables/UserText.swift | 3 +- .../Chromium/ChromiumBookmarksReader.swift | 7 + .../Chromium/ChromiumFaviconsReader.swift | 2 + .../Firefox/FirefoxBookmarksReader.swift | 8 + .../Firefox/FirefoxFaviconsReader.swift | 2 + .../Bookmarks/HTML/BookmarkHTMLReader.swift | 2 + .../Safari/SafariBookmarksReader.swift | 2 + .../Safari/SafariFaviconsReader.swift | 2 + DuckDuckGo/DataImport/DataImport.swift | 70 +- .../DataImport/Logins/CSV/CSVImporter.swift | 6 +- .../Logins/Chromium/ChromiumLoginReader.swift | 10 +- .../Logins/Firefox/FirefoxLoginReader.swift | 11 +- .../Model/DataImportSourceViewModel.swift | 6 +- .../Model/DataImportSummaryViewModel.swift | 61 ++ .../Model/DataImportViewModel.swift | 364 ++++++---- .../DataImport/View/DataImportErrorView.swift | 19 - .../View/DataImportNoDataView.swift | 45 ++ .../View/DataImportSourcePicker.swift | 7 +- .../View/DataImportSummaryView.swift | 77 ++- .../DataImport/View/DataImportView.swift | 197 ++++-- .../DataImport/View/FileImportView.swift | 2 +- .../DataImport/View/ReportFeedbackView.swift | 39 +- DuckDuckGo/Localizable.xcstrings | 107 ++- DuckDuckGo/Statistics/PixelArguments.swift | 2 +- .../DataImport/BrowserProfileTests.swift | 2 +- .../DataImportSourceViewModelTests.swift | 69 ++ .../DataImport/DataImportViewModelTests.swift | 648 ++++++++++++++++++ ...rdsImportFails_manualImportSuggested.1.txt | 68 ++ ...dsImportFails_manualImportSuggested.10.txt | 68 ++ ...dsImportFails_manualImportSuggested.11.txt | 68 ++ ...dsImportFails_manualImportSuggested.12.txt | 68 ++ ...dsImportFails_manualImportSuggested.13.txt | 68 ++ ...rdsImportFails_manualImportSuggested.2.txt | 68 ++ ...rdsImportFails_manualImportSuggested.3.txt | 68 ++ ...rdsImportFails_manualImportSuggested.4.txt | 68 ++ ...rdsImportFails_manualImportSuggested.5.txt | 68 ++ ...rdsImportFails_manualImportSuggested.6.txt | 68 ++ ...rdsImportFails_manualImportSuggested.7.txt | 68 ++ ...rdsImportFails_manualImportSuggested.8.txt | 68 ++ ...rdsImportFails_manualImportSuggested.9.txt | 68 ++ 42 files changed, 2379 insertions(+), 318 deletions(-) create mode 100644 DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift delete mode 100644 DuckDuckGo/DataImport/View/DataImportErrorView.swift create mode 100644 DuckDuckGo/DataImport/View/DataImportNoDataView.swift create mode 100644 UnitTests/DataImport/DataImportSourceViewModelTests.swift create mode 100644 UnitTests/DataImport/DataImportViewModelTests.swift create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt create mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5672de49de..4a171823e1 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -3408,6 +3408,10 @@ B6619EFC2B111CC600CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; B6619EFD2B111CCA00CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; B6619EFE2B111CCC00CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; + B6619F032B17123200CD9186 /* DataImportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */; }; + B6619F042B17123200CD9186 /* DataImportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */; }; + B6619F062B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */; }; + B6619F072B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */; }; B66260DD29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260DE29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260E029AC6EBD00E9E3EE /* HistoryTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */; }; @@ -3516,6 +3520,12 @@ B69B50542726CD8100758A2B /* atb-with-update.json in Resources */ = {isa = PBXBuildFile; fileRef = B69B50502726CD7F00758A2B /* atb-with-update.json */; }; B69B50552726CD8100758A2B /* invalid.json in Resources */ = {isa = PBXBuildFile; fileRef = B69B50512726CD8000758A2B /* invalid.json */; }; B69B50572727D16900758A2B /* AtbAndVariantCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */; }; + B6A22B392B19F91E00ECD2BA /* __Snapshots__ in Resources */ = {isa = PBXBuildFile; fileRef = B6A22B382B19F91E00ECD2BA /* __Snapshots__ */; }; + B6A22B3A2B19F91E00ECD2BA /* __Snapshots__ in Resources */ = {isa = PBXBuildFile; fileRef = B6A22B382B19F91E00ECD2BA /* __Snapshots__ */; }; + B6A22B622B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; + B6A22B632B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; + B6A22B642B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; + B6A22B652B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; B6A5A27125B9377300AA7ADA /* StatePersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A5A27025B9377300AA7ADA /* StatePersistenceService.swift */; }; B6A5A27925B93FFF00AA7ADA /* StateRestorationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A5A27825B93FFE00AA7ADA /* StateRestorationManagerTests.swift */; }; B6A5A27E25B9403E00AA7ADA /* FileStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A5A27D25B9403E00AA7ADA /* FileStoreMock.swift */; }; @@ -3554,6 +3564,10 @@ B6B4D1C62B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; B6B4D1C72B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; B6B4D1C82B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1CF2B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; + B6B4D1D02B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; + B6B4D1D12B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; + B6B4D1D22B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; B6B5F57F2B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; B6B5F5802B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; B6B5F5812B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; @@ -4780,6 +4794,8 @@ B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSBezierPathExtension.swift; sourceTree = ""; }; B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsFormatParserTests.swift; sourceTree = ""; }; B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsFormatParser.swift; sourceTree = ""; }; + B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportViewModelTests.swift; sourceTree = ""; }; + B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSourceViewModelTests.swift; sourceTree = ""; }; B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationProtectionTabExtension.swift; sourceTree = ""; }; B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryTabExtension.swift; sourceTree = ""; }; B66260E529ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHotkeyHandler.swift; sourceTree = ""; }; @@ -4854,6 +4870,8 @@ B69B50502726CD7F00758A2B /* atb-with-update.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "atb-with-update.json"; sourceTree = ""; }; B69B50512726CD8000758A2B /* invalid.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = invalid.json; sourceTree = ""; }; B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AtbAndVariantCleanup.swift; sourceTree = ""; }; + B6A22B382B19F91E00ECD2BA /* __Snapshots__ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = __Snapshots__; sourceTree = ""; }; + B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSummaryViewModel.swift; sourceTree = ""; }; B6A5A27025B9377300AA7ADA /* StatePersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatePersistenceService.swift; sourceTree = ""; }; B6A5A27825B93FFE00AA7ADA /* StateRestorationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorationManagerTests.swift; sourceTree = ""; }; B6A5A27D25B9403E00AA7ADA /* FileStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStoreMock.swift; sourceTree = ""; }; @@ -4881,6 +4899,7 @@ B6B2400D28083B49001B8F3A /* WebViewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContainerView.swift; sourceTree = ""; }; B6B3E0DC2657E9CF0040E0A2 /* NSScreenExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSScreenExtension.swift; sourceTree = ""; }; B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportReportModel.swift; sourceTree = ""; }; + B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportNoDataView.swift; sourceTree = ""; }; B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSummaryView.swift; sourceTree = ""; }; B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestFilePermissionView.swift; sourceTree = ""; }; B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMoreInfoView.swift; sourceTree = ""; }; @@ -6107,6 +6126,7 @@ B62B48552ADE730D000DECE5 /* FileImportView.swift */, B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */, B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */, + B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */, 4B8AC93426B3B2FD00879451 /* NSAlert+DataImport.swift */, B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */, B677FC4E2B06376B0099EB04 /* ReportFeedbackView.swift */, @@ -6155,6 +6175,7 @@ 4B723DFE26B0003E00E14D75 /* DataImport */ = { isa = PBXGroup; children = ( + B6A22B382B19F91E00ECD2BA /* __Snapshots__ */, 373A1AB128451ED400586521 /* BookmarksHTMLImporterTests.swift */, 373A1AA9283ED86C00586521 /* BookmarksHTMLReaderTests.swift */, 4B3F641D27A8D3BD00E0C118 /* BrowserProfileTests.swift */, @@ -6164,6 +6185,8 @@ 4B723E0126B0003E00E14D75 /* CSVImporterTests.swift */, 4B723E0026B0003E00E14D75 /* CSVParserTests.swift */, 4B723DFF26B0003E00E14D75 /* DataImportMocks.swift */, + B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */, + B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */, B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */, 4BB99D0D26FE1A83001E4761 /* FirefoxBookmarksReaderTests.swift */, 4B98D27B28D960DD003C2B6F /* FirefoxFaviconsReaderTests.swift */, @@ -8498,6 +8521,7 @@ B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */, B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */, B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */, + B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */, B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */, ); path = Model; @@ -8797,6 +8821,8 @@ 37079A95294236FA0031BB3C /* PBXTargetDependency */, ); name = "Integration Tests App Store"; + packageProductDependencies = ( + ); productName = "Integration Tests"; productReference = 3706FEB2293F662100E42796 /* Integration Tests App Store.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -9420,6 +9446,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B6A22B3A2B19F91E00ECD2BA /* __Snapshots__ in Resources */, 3706FE8B293F661700E42796 /* empty in Resources */, 376E2D282942843D001CD31B /* privacy-reference-tests in Resources */, 3706FE8C293F661700E42796 /* atb-with-update.json in Resources */, @@ -9655,6 +9682,7 @@ 31E163C0293A581900963C10 /* privacy-reference-tests in Resources */, B69B50542726CD8100758A2B /* atb-with-update.json in Resources */, 37A803DB27FD69D300052F4C /* DataImportResources in Resources */, + B6A22B392B19F91E00ECD2BA /* __Snapshots__ in Resources */, B69B50522726CD8100758A2B /* atb.json in Resources */, 4B70C00127B0793D000386ED /* DuckDuckGo-ExampleCrash.ips in Resources */, 4BCF15ED2ABB9B180083F6DF /* network-protection-messages.json in Resources */, @@ -10118,6 +10146,7 @@ 31929FC52A4C4CFF0084EA89 /* ContextMenuManager.swift in Sources */, 31929FC62A4C4CFF0084EA89 /* GradientView.swift in Sources */, 31929FC72A4C4CFF0084EA89 /* PreferencesSidebar.swift in Sources */, + B6B4D1D12B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, 31929FC82A4C4CFF0084EA89 /* HoveredLinkTabExtension.swift in Sources */, 4B9DB02B2A983B24000927DB /* WaitlistStorage.swift in Sources */, 31929FC92A4C4CFF0084EA89 /* NSPointExtension.swift in Sources */, @@ -10579,6 +10608,7 @@ 3192A16B2A4C4CFF0084EA89 /* TabCollectionViewModel+NSSecureCoding.swift in Sources */, 3192A16C2A4C4CFF0084EA89 /* StringExtension.swift in Sources */, 3192A16D2A4C4CFF0084EA89 /* EmailManagerRequestDelegate.swift in Sources */, + B6A22B642B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 3198FCAC2A5D8A3C002EF5F8 /* AppLauncher.swift in Sources */, 3192A16E2A4C4CFF0084EA89 /* ApplicationVersionReader.swift in Sources */, 3192A16F2A4C4CFF0084EA89 /* BookmarksBarViewController.swift in Sources */, @@ -11332,6 +11362,7 @@ 3706FC54293F65D500E42796 /* BookmarkListTreeControllerDataSource.swift in Sources */, 3706FC55293F65D500E42796 /* AddressBarViewController.swift in Sources */, 3706FC56293F65D500E42796 /* Permissions.swift in Sources */, + B6B4D1D02B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, 3706FC57293F65D500E42796 /* TabPreviewWindowController.swift in Sources */, 3706FC58293F65D500E42796 /* NSSizeExtension.swift in Sources */, 3706FC59293F65D500E42796 /* Fire.swift in Sources */, @@ -11414,6 +11445,7 @@ 3706FC9A293F65D500E42796 /* AutoconsentManagement.swift in Sources */, 3706FC9C293F65D500E42796 /* BookmarkStore.swift in Sources */, 3706FC9D293F65D500E42796 /* PrivacyDashboardViewController.swift in Sources */, + B6A22B632B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 3706FC9E293F65D500E42796 /* PreferencesAppearanceView.swift in Sources */, 3706FC9F293F65D500E42796 /* NSMenuItemExtension.swift in Sources */, 3706FCA0293F65D500E42796 /* ContiguousBytesExtension.swift in Sources */, @@ -11553,6 +11585,7 @@ 3706FE3D293F661700E42796 /* DataEncryptionTests.swift in Sources */, 3706FE3E293F661700E42796 /* ClickToLoadModelTests.swift in Sources */, 3706FE3F293F661700E42796 /* FileStoreMock.swift in Sources */, + B6619F042B17123200CD9186 /* DataImportViewModelTests.swift in Sources */, 3706FE40293F661700E42796 /* BWResponseTests.swift in Sources */, 3706FE41293F661700E42796 /* DownloadListCoordinatorTests.swift in Sources */, 986189E72A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift in Sources */, @@ -11561,6 +11594,7 @@ 3706FE44293F661700E42796 /* GeolocationServiceTests.swift in Sources */, 1DA6D1032A1FFA3B00540406 /* HTTPCookieTests.swift in Sources */, 3706FE45293F661700E42796 /* ProgressEstimationTests.swift in Sources */, + B6619F072B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */, 3706FE46293F661700E42796 /* EncryptedValueTransformerTests.swift in Sources */, 3706FE47293F661700E42796 /* URLExtensionTests.swift in Sources */, 1DB9617B29F1D06D00CF5568 /* InternalUserDeciderMock.swift in Sources */, @@ -12174,6 +12208,7 @@ B62B48412ADE48DE000DECE5 /* MenuBuilder.swift in Sources */, 4B957A7D2AC7AE700062CA31 /* IndexPathExtension.swift in Sources */, 4B957A7E2AC7AE700062CA31 /* PasswordManagementNoteItemView.swift in Sources */, + B6B4D1D22B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, 4B957A7F2AC7AE700062CA31 /* NSApplicationExtension.swift in Sources */, 4B957A802AC7AE700062CA31 /* NSWindowExtension.swift in Sources */, 4B957A812AC7AE700062CA31 /* KeychainType+ClientDefault.swift in Sources */, @@ -12408,6 +12443,7 @@ 4B957B602AC7AE700062CA31 /* FavoritesView.swift in Sources */, 4B957B612AC7AE700062CA31 /* HomePage.swift in Sources */, 4B957B622AC7AE700062CA31 /* RoundedSelectionRowView.swift in Sources */, + B6A22B652B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 4B957B632AC7AE700062CA31 /* LocalStatisticsStore.swift in Sources */, 4B957B642AC7AE700062CA31 /* BackForwardListItem.swift in Sources */, 4B957B662AC7AE700062CA31 /* CookieConsentUserPermissionView.swift in Sources */, @@ -13029,6 +13065,7 @@ AAA0CC6A253CC43C0079BC96 /* WKUserContentControllerExtension.swift in Sources */, 4BE65479271FCD41008D1D63 /* EditableTextView.swift in Sources */, AA9FF95D24A1FA1C0039E328 /* TabCollection.swift in Sources */, + B6B4D1CF2B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, B688B4DA273E6D3B0087BEAF /* MainView.swift in Sources */, B61E2CD5294346C000773D8A /* Tab+Navigation.swift in Sources */, 4B65143E263924B5005B46EB /* EmailUrlExtensions.swift in Sources */, @@ -13243,6 +13280,7 @@ 37D2771527E870D4003365FD /* PreferencesAppearanceView.swift in Sources */, AA72D5FE25FFF94E00C77619 /* NSMenuItemExtension.swift in Sources */, 4BA1A6C2258B0A1300F6F690 /* ContiguousBytesExtension.swift in Sources */, + B6A22B622B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, 4BCF15DB2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, 987799F62999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, @@ -13277,6 +13315,7 @@ 85F1B0C925EF9759004792B6 /* URLEventHandlerTests.swift in Sources */, 4B9292BD2667103100AD2C21 /* BookmarkOutlineViewDataSourceTests.swift in Sources */, 4BF6961D28BE911100D402D4 /* RecentlyVisitedSiteModelTests.swift in Sources */, + B6619F062B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */, 4BBF0917282DD6EF00EE1418 /* TemporaryFileHandlerTests.swift in Sources */, B6A5A27925B93FFF00AA7ADA /* StateRestorationManagerTests.swift in Sources */, B630E7FE29C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, @@ -13398,6 +13437,7 @@ 1DA6D1022A1FFA3700540406 /* HTTPCookieTests.swift in Sources */, B68172AE269EB43F006D1092 /* GeolocationServiceTests.swift in Sources */, B6AE74342609AFCE005B9B1A /* ProgressEstimationTests.swift in Sources */, + B6619F032B17123200CD9186 /* DataImportViewModelTests.swift in Sources */, 4BA1A6FE258C5C1300F6F690 /* EncryptedValueTransformerTests.swift in Sources */, 85F69B3C25EDE81F00978E59 /* URLExtensionTests.swift in Sources */, B6DA44112616C0FC00DD1EC2 /* PixelTests.swift in Sources */, diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme index 70c4322cf6..a280d3c9f1 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme @@ -54,7 +54,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 53ecb62650..895ddb1f80 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -512,8 +512,7 @@ struct UserText { static let initiateImport = NSLocalizedString("import.data.initiate", value: "Import", comment: "Button text for importing data") static let skipImport = NSLocalizedString("import.data.skip", value: "Skip", comment: "Button text to skip a kind of imported data") static let done = NSLocalizedString("import.data.done", value: "Done", comment: "Button text for finishing the data import") - - static let dataImportFailedTitle = NSLocalizedString("import.data.import-failed.title", value: "Sorry, we weren't able to import your data.", comment: "Alert title when the data import fails") + static let manualImport = NSLocalizedString("import.data.manual", value: "Manual import…", comment: "Button text for initiating manual data import using a HTML or CSV file when automatic import has failed") static let dataImportAlertImport = NSLocalizedString("import.data.alert.import", value: "Import", comment: "Import button for data import alerts") static let dataImportAlertCancel = NSLocalizedString("import.data.alert.cancel", value: "Cancel", comment: "Cancel button for data import alerts") diff --git a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift index ebb946a18d..712be38829 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift @@ -33,6 +33,13 @@ final class ChromiumBookmarksReader { var action: DataImportAction { .bookmarks } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { + switch type { + case .fileRead: .noData + case .decodeJson: .dataCorrupted + } + } } private let chromiumBookmarksFileURL: URL diff --git a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift index 3fae74e514..2922936cae 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift @@ -35,6 +35,8 @@ final class ChromiumFaviconsReader { var action: DataImportAction { .favicons } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .other } } func importError(type: ImportError.OperationType, underlyingError: Error) -> ImportError { ImportError(type: type, underlyingError: underlyingError) diff --git a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift index 9651196020..3cb4c8e2a1 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift @@ -49,6 +49,14 @@ final class FirefoxBookmarksReader { var action: DataImportAction { .bookmarks } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { + switch type { + case .dbOpen: .noData + case .fetchRootEntries, .noRootEntries, .fetchTopLevelFolders, .fetchAllBookmarks, .fetchAllFolders: .dataCorrupted + case .copyTemporaryFile: .other + } + } } private let firefoxPlacesDatabaseURL: URL diff --git a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift index 7b15a19091..442d4ea913 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift @@ -35,6 +35,8 @@ final class FirefoxFaviconsReader { var action: DataImportAction { .favicons } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .other } } final class FirefoxFavicon: FetchableRecord { diff --git a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift index 920835f1c0..cf3616e7bb 100644 --- a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift @@ -40,6 +40,8 @@ final class BookmarkHTMLReader { var action: DataImportAction { .bookmarks } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .dataCorrupted } } private var currentOperationType: ImportError.OperationType = .parseXml diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift index dbd74aa9fd..2a0253fe94 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift @@ -46,6 +46,8 @@ final class SafariBookmarksReader { var action: DataImportAction { .bookmarks } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .dataCorrupted } } private let safariBookmarksFileURL: URL diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift index ed84ffb037..0e35e730aa 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift @@ -37,6 +37,8 @@ final class SafariFaviconsReader { var action: DataImportAction { .favicons } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .other } } fileprivate final class SafariFaviconRecord: FetchableRecord { diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 27d9018ba8..82348a0eb2 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -131,7 +131,8 @@ enum DataImport { } - enum DataType: String, Hashable, CaseIterable { + enum DataType: String, Hashable, CaseIterable, CustomStringConvertible { + case bookmarks case passwords @@ -141,6 +142,9 @@ enum DataImport { case .passwords: UserText.importLoginsPasswords } } + + var description: String { rawValue } + } struct DataTypeSummary: Equatable { @@ -327,13 +331,30 @@ enum DataImport { } } + enum ErrorType: String, CustomStringConvertible, CaseIterable { + case noData + case decryptionError + case dataCorrupted + case keychainError + case other + + var description: String { rawValue } + } + } enum DataImportAction { case bookmarks - case logins + case passwords case favicons case generic + + init(_ type: DataImport.DataType) { + switch type { + case .bookmarks: self = .bookmarks + case .passwords: self = .passwords + } + } } protocol DataImportError: Error, CustomNSError, ErrorWithPixelParameters, LocalizedError { @@ -343,6 +364,8 @@ protocol DataImportError: Error, CustomNSError, ErrorWithPixelParameters, Locali var type: OperationType { get } var underlyingError: Error? { get } + var errorType: DataImport.ErrorType { get } + } extension DataImportError /* : CustomNSError */ { var errorCode: Int { @@ -416,7 +439,7 @@ extension DataImporter { } -enum DataImportResult { +enum DataImportResult: CustomStringConvertible { case success(T) case failure(any DataImportError) @@ -477,6 +500,15 @@ enum DataImportResult { } } + var description: String { + switch self { + case .success(let value): + ".success(\(value))" + case .failure(let error): + ".success(\(error))" + } + } + } extension DataImportResult: Equatable where T: Equatable { @@ -504,14 +536,14 @@ struct LoginImporterError: DataImportError { private let error: Error? private let _type: OperationType? - var action: DataImportAction { .logins } + var action: DataImportAction { .passwords } init(error: Error?, type: OperationType? = nil) { self.error = error self._type = type } - struct OperationType: RawRepresentable { + struct OperationType: RawRepresentable, Equatable { let rawValue: Int static let malformedCSV = OperationType(rawValue: -2) @@ -551,4 +583,32 @@ struct LoginImporterError: DataImportError { } } + var errorType: DataImport.ErrorType { + if case .malformedCSV = type { + return .dataCorrupted + } + if let secureStorageError = error as? SecureStorageError { + switch secureStorageError { + case .initFailed, + .authError, + .failedToOpenDatabase, + .databaseError: + return .keychainError + + case .keystoreError, .secError: + return .keychainError + + case .authRequired, + .invalidPassword, + .noL1Key, + .noL2Key, + .duplicateRecord, + .generalCryptoError, + .encodingFailed: + return .decryptionError + } + } + return .other + } + } diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index 8a2d23a3ca..e74c3e4924 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -143,9 +143,13 @@ final class CSVImporter: DataImporter { case cannotReadFile } - var action: DataImportAction { .logins } + var action: DataImportAction { .passwords } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { + .dataCorrupted + } } private let fileURL: URL diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift index 070b169731..65fa251e98 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift @@ -36,10 +36,18 @@ final class ChromiumLoginReader { case createImportedLoginCredentialsFailure } - var action: DataImportAction { .logins } + var action: DataImportAction { .passwords } let type: OperationType var underlyingError: Error? + var errorType: DataImport.ErrorType { + switch type { + case .couldNotFindLoginData, .failedToTemporarilyCopyDatabase: .noData + case .databaseAccessFailed: .dataCorrupted + case .decryptionFailed, .failedToDecodePasswordData, .decryptionKeyAccessFailed, .passwordDataTooShort, .dataToStringConversionError: .decryptionError + case .userDeniedKeychainPrompt, .createImportedLoginCredentialsFailure: .other + } + } } private func decryptionKeyAccessFailed(_ status: OSStatus) -> ImportError { diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift index 2ffd85a665..3c354886cc 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift @@ -43,9 +43,18 @@ final class FirefoxLoginReader { case decryptPassword } - var action: DataImportAction { .logins } + var action: DataImportAction { .passwords } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { + switch type { + case .couldNotFindLoginsFile, .couldNotReadLoginsFile: .noData + case .key3readerStage1, .key3readerStage2, .key3readerStage3, .key4readerStage1, .key4readerStage2, .key4readerStage3, .decryptUsername, .decryptPassword: .decryptionError + case .couldNotDetermineFormat: .dataCorrupted + case .requiresPrimaryPassword: .other + } + } } typealias LoginReaderFileLineError = FileLineError diff --git a/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift index 1f85a44b7d..ddd53cb310 100644 --- a/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift @@ -23,9 +23,7 @@ struct DataImportSourceViewModel { let importSources: [DataImport.Source?] var selectedSourceIndex: Int - let onSelectedSourceChanged: (DataImport.Source) -> Void - - init(importSources: [DataImport.Source]? = nil, selectedSource: DataImport.Source, onSelectedSourceChanged: @escaping (DataImport.Source) -> Void) { + init(importSources: [DataImport.Source]? = nil, selectedSource: DataImport.Source) { var importSources: [DataImport.Source?] = (importSources ?? DataImport.Source.allCases.filter(\.canImportData)) // The CSV row is at the bottom of the picker, and requires a separator above it, but only if the item array isn't @@ -42,8 +40,6 @@ struct DataImportSourceViewModel { self.selectedSourceIndex = self.importSources.firstIndex(of: selectedSource) ?? 0 assert(self.importSources.indices.contains(selectedSourceIndex)) - - self.onSelectedSourceChanged = onSelectedSourceChanged } } diff --git a/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift new file mode 100644 index 0000000000..53f5902268 --- /dev/null +++ b/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift @@ -0,0 +1,61 @@ +// +// DataImportSummaryViewModel.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct DataImportSummaryViewModel { + typealias Source = DataImport.Source + typealias DataType = DataImport.DataType + typealias DataTypeImportResult = DataImportViewModel.DataTypeImportResult + typealias DataTypeSummary = DataImport.DataTypeSummary + + enum SummaryKind { + // total + case results + // one data type only + case importComplete(DataType) + // file import per data type + case fileImportComplete(DataType) + } + let summaryKind: SummaryKind + let results: [DataTypeImportResult] + + init(source: Source, results: [DataTypeImportResult], dataTypes: Set? = nil) { + let dataTypes = dataTypes ?? Set(results.map(\.dataType)) + assert(!dataTypes.isEmpty) + + if dataTypes.count > 1 { + self.summaryKind = .results + + } else { + let dataType = dataTypes.first ?? .bookmarks + + let isFileImport = source.initialScreen.isFileImport || results.filter({ $0.dataType == dataType }).count > 1 /* is file import retry */ + self.summaryKind = isFileImport ? .fileImportComplete(dataType) : .importComplete(dataType) + } + + self.results = DataType.allCases.compactMap { dataType in + dataTypes.contains(dataType) ? results.last(where: { $0.dataType == dataType }) : nil + } + } + + init(source: Source, summary: [DataType: DataImportResult]) { + self.init(source: source, results: summary.reduce(into: [], { $0.append(.init($1.key, $1.value)) }), dataTypes: Set(summary.keys)) + } + +} diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 1385d97ce4..041e3b279c 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -52,18 +52,34 @@ struct DataImportViewModel { /// Factory for a DataImporter for importSource private let reportSenderFactory: ReportSenderFactory + private func log(_ message: @autoclosure () -> String) { + if OSLog.dataImportExport != .disabled { + os_log(log: .dataImportExport, message()) + } else if NSApp.runType == .xcPreviews { + print(message()) + } + } + enum Screen: Hashable { case profileAndDataTypesPicker case moreInfo case getReadPermission(URL) - case fileImport(DataType) - case fileImportSummary(DataType) - case summary + case fileImport(dataType: DataType, summary: Set = []) + case summary(Set) + case noData(DataType) case feedback + var isFileImport: Bool { + if case .fileImport = self { true } else { false } + } var fileImportDataType: DataType? { - if case .fileImport(let dataType) = self { return dataType } - return nil + switch self { + case .fileImport(dataType: let dataType, summary: _), + .noData(let dataType): + return dataType + default: + return nil + } } } /// Currently displayed screen @@ -78,33 +94,72 @@ struct DataImportViewModel { /// used to cancel import and in `importProgress` to trace import progress and import completion private var importTask: DataImportTask? - typealias DataImportViewSummary = [(dataType: DataImport.DataType, result: DataImportResult)] + struct DataTypeImportResult: Equatable { + let dataType: DataImport.DataType + let result: DataImportResult + init(_ dataType: DataImport.DataType, _ result: DataImportResult) { + self.dataType = dataType + self.result = result + } + } + /// collected import summary for current import operation per selected import source - private(set) var summary = DataImportViewSummary() + private(set) var summary: [DataTypeImportResult] private var userReportText: String = "" +#if DEBUG || REVIEW + + enum ImportError: DataImportError { + enum OperationType: Int { + case imp + } + + var type: OperationType { .imp } + var action: DataImportAction { .generic } + var underlyingError: Error? { + if case .err(let err) = self { + return err + } + return nil + } + var errorType: DataImport.ErrorType { .noData } + + case err(Error) + } + + var testImportFailureReasons = [DataType: DataImport.ErrorType]() + +#endif + init(importSource: Source? = nil, + screen: Screen? = nil, + availableImportSources: @autoclosure () -> Set = Set(ThirdPartyBrowser.installedBrowsers.map(\.importSource)), + preferredImportSources: [Source] = [.chrome, .firefox, .safari], + summary: [DataTypeImportResult] = [], loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() }, dataImporterFactory: @escaping DataImporterFactory = dataImporter, requestPrimaryPasswordCallback: @escaping @MainActor (Source) -> String? = Self.requestPrimaryPasswordCallback, openPanelCallback: @escaping @MainActor (DataType) -> URL? = Self.openPanelCallback, reportSenderFactory: @escaping ReportSenderFactory = { FeedbackSender().sendDataImportReport }) { - let importSource = importSource ?? ThirdPartyBrowser.installedBrowsers.first?.importSource ?? .csv + lazy var availableImportSources = availableImportSources() + let importSource = importSource ?? preferredImportSources.first(where: { availableImportSources.contains($0) }) ?? .csv self.importSource = importSource self.loadProfiles = loadProfiles self.dataImporterFactory = dataImporterFactory - self.screen = importSource.initialScreen + self.screen = screen ?? importSource.initialScreen self.browserProfiles = ThirdPartyBrowser.browser(for: importSource).map(loadProfiles) self.selectedProfile = browserProfiles?.defaultProfile self.selectedDataTypes = importSource.supportedDataTypes - self.requestPrimaryPasswordCallback = requestPrimaryPasswordCallback + self.summary = summary + + self.requestPrimaryPasswordCallback = requestPrimaryPasswordCallback self.openPanelCallback = openPanelCallback self.reportSenderFactory = reportSenderFactory } @@ -116,13 +171,14 @@ struct DataImportViewModel { assertionFailure("URL not provided") return } + assert(actionButton == .initiateImport(disabled: false) || screen.fileImportDataType != nil) // are we handling file import or browser selected data types import? let dataType: DataType? = self.screen.fileImportDataType let dataTypes = dataType.map { [$0] } ?? selectedDataTypes let importer = dataImporterFactory(importSource, dataType, url, primaryPassword) - os_log(.debug, log: .dataImportExport, "import \(dataTypes) at \"\(url.path)\" using \(type(of: importer))") + log("import \(dataTypes) at \"\(url.path)\" using \(type(of: importer))") // validate file access/encryption password requirement before starting import if let errors = importer.validateAccess(for: dataTypes), @@ -130,6 +186,38 @@ struct DataImportViewModel { return } + // simulated test import failures +#if DEBUG || REVIEW + struct TestImportError: DataImportError { + enum OperationType: Int { + case imp + } + var type: OperationType { .imp } + var action: DataImportAction + var underlyingError: Error? { CocoaError(.fileReadUnknown) } + var errorType: DataImport.ErrorType + } + + guard dataTypes.compactMap({ testImportFailureReasons[$0] }).isEmpty else { + importTask = .detachedWithProgress { [testImportFailureReasons] progressUpdate in + var result = DataImportSummary() + let selectedDataTypesWithoutFailureReasons = dataTypes.intersection(importer.importableTypes).subtracting(testImportFailureReasons.keys) + var realSummary = DataImportSummary() + if !selectedDataTypesWithoutFailureReasons.isEmpty { + realSummary = await importer.importData(types: selectedDataTypesWithoutFailureReasons).task.value + } + for dataType in dataTypes { + if let failureReason = testImportFailureReasons[dataType] { + result[dataType] = .failure(TestImportError(action: .init(dataType), errorType: failureReason)) + } else { + result[dataType] = realSummary[dataType] + } + } + return result + } + return + } +#endif importTask = importer.importData(types: dataTypes) } @@ -138,26 +226,46 @@ struct DataImportViewModel { private mutating func mergeImportSummary(with summary: DataImportSummary) { self.importTask = nil - os_log(.debug, log: .dataImportExport, "merging summary \(summary)") + log("merging summary \(summary)") if handleErrors(summary.compactMapValues { $0.error }) { return } + var nextScreen: Screen? // merge new import results into the model import summary - for (dataType, result) in summary { - self.summary.append( (dataType, result) ) + for (dataType, result) in DataType.allCases.compactMap({ dataType in summary[dataType].map { (dataType, $0) } }) { + self.summary.append( .init(dataType, result) ) - if case .failure(let error) = result { + switch result { + case .success(let summary): + if summary.successful == 0 && summary.duplicate == 0 && summary.failed == 0, nextScreen == nil { + nextScreen = .noData(dataType) + } + case .failure(let error): + if case .noData = error.errorType, nextScreen == nil { + nextScreen = .noData(dataType) + } Pixel.fire(.dataImportFailed(source: importSource, error: error)) } } + + if let nextScreen { + self.screen = nextScreen + } else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil, // no next data type manual import screen + // and there should be failed data types (and non-recovered) + selectedDataTypes.contains(where: { dataType in self.summary.last(where: { $0.dataType == dataType })?.result.error != nil }) { + // after last failed datatype show feedback + self.screen = .feedback + } else { + self.screen = .summary(Set(summary.keys)) + } + if self.areAllSelectedDataTypesSuccessfullyImported { successfulImportHappened = true NotificationCenter.default.post(name: .dataImportComplete, object: nil) } - self.screen = nextScreen() - os_log(.debug, log: .dataImportExport, "next screen: \(screen)") + log("next screen: \(screen)") } /// handle recoverable errors (request primary password or file permission) @@ -173,7 +281,7 @@ struct DataImportViewModel { // firefox passwords db is master-password protected: request password case let error as FirefoxLoginReader.ImportError where error.type == .requiresPrimaryPassword: - os_log(.debug, log: .dataImportExport, "primary password required") + log("primary password required") // stay on the same screen but request password synchronously if let password = self.requestPrimaryPasswordCallback(importSource) { self.initiateImport(primaryPassword: password) @@ -187,7 +295,7 @@ struct DataImportViewModel { assertionFailure("No url") break } - os_log(.debug, log: .dataImportExport, "file read no permission for \(url.path)") + log("file read no permission for \(url.path)") screen = .getReadPermission(url) return true @@ -197,45 +305,28 @@ struct DataImportViewModel { return false } - /// returns next screen after import competion (or after Skip button press) - private func nextScreen(skip: Bool = false) -> Screen { - switch screen { - case .profileAndDataTypesPicker, .moreInfo, .getReadPermission: - if let dataType = nextDataTypeRemainingToImport() { - return .fileImport(dataType) - } - - case .fileImport(let dataType), - .fileImportSummary(let dataType): - // all remaining DataTypes in fixed sort order after current file import data type - if let nextDataType = nextDataTypeRemainingToImport(after: dataType) { - // show File Import summary if there‘s next File Import ahead - if case .fileImport = screen, - // and not the Skip button was pressed - !skip, - // and file import operation was successful - // - otherwise will display report afterwards - isDataTypeSuccessfullyImported(dataType) { - return .fileImportSummary(dataType) - } - return .fileImport(nextDataType) - } - - case .summary, .feedback: - break + /// Skip button press + @MainActor mutating func skipImport() { + if let screen = screenForNextDataTypeRemainingToImport(after: screen.fileImportDataType) { + // skip to next non-imported data type + self.screen = screen + } else if selectedDataTypes.first(where: { error(for: $0) != nil }) != nil { + // errors occurred during import: show feedback screen + self.screen = .feedback + } else { + // display total summary + self.screen = .summary(selectedDataTypes) } - // all done - return areAllSelectedDataTypesSuccessfullyImported ? .summary : .feedback } - /// Skip button press - @MainActor mutating func skipImport() { - self.screen = nextScreen(skip: true) + /// Open Manual File Import screen action + mutating func manualImport(dataType: DataType) { + screen = .fileImport(dataType: dataType) } /// Select CSV/HTML file for import button press @MainActor mutating func selectFile() { - guard case .fileImport(let dataType) = screen else { + guard let dataType = screen.fileImportDataType else { assertionFailure("Expected File Import") return } @@ -312,13 +403,12 @@ extension DataImport.Source { var initialScreen: DataImportViewModel.Screen { switch self { case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, - .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, - .yandex: + .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: return .profileAndDataTypesPicker case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv: - return .fileImport(.passwords) + return .fileImport(dataType: .passwords) case .bookmarksHTML: - return .fileImport(.bookmarks) + return .fileImport(dataType: .bookmarks) } } @@ -346,19 +436,35 @@ extension DataImportViewModel { selectedDataTypes.allSatisfy(isDataTypeSuccessfullyImported) } - private func nextDataTypeRemainingToImport(after currentDataType: DataType? = nil) -> DataType? { - // keep the original sort order - (currentDataType.map { DataType.dataTypes(after: $0) } ?? DataType.allCases[0...]) // among all data types or only after some? - .first(where: { dataType in - // if some of selected data types failed to import or not imported yet - selectedDataTypes.contains(dataType) && !isDataTypeSuccessfullyImported(dataType) + private func isDataTypeSuccessfullyImported(_ dataType: DataType) -> Bool { + summary.reversed().contains(where: { dataTypeImportResult in + dataType == dataTypeImportResult.dataType && dataTypeImportResult.result.isSuccess }) } - private func isDataTypeSuccessfullyImported(_ dataType: DataType) -> Bool { - summary.reversed().contains(where: { (summaryDataType, result) in - dataType == summaryDataType && result.isSuccess - }) + private func screenForNextDataTypeRemainingToImport(after currentDataType: DataType? = nil) -> Screen? { + // keep the original sort order among all data types or only after current data type + for dataType in (currentDataType.map { DataType.dataTypes(after: $0) } ?? DataType.allCases[0...]) where selectedDataTypes.contains(dataType) { + // if some of selected data types failed to import or not imported yet + switch summary.last(where: { $0.dataType == dataType })?.result { + case .success(let summary) where summary.successful == 0 && summary.duplicate == 0 && summary.failed == 0: + return .noData(dataType) + case .failure(let error) where error.errorType == .noData: + return .noData(dataType) + case .failure, .none: + return .fileImport(dataType: dataType) + case .success: + continue + } + } + return nil + } + + private func error(for dataType: DataType) -> (any DataImportError)? { + if case .failure(let error) = summary.last(where: { $0.dataType == dataType })?.result { + return error + } + return nil } private struct DataImportViewSummarizedError: LocalizedError { @@ -381,8 +487,8 @@ extension DataImportViewModel { func hasDataTypeImportFailed(_ dataType: DataType) -> Bool { var failureFound = false - for (summaryDataType, result) in summary.reversed() where summaryDataType == dataType { - switch result { + for dataTypeImportResult in summary.reversed() where dataTypeImportResult.dataType == dataType { + switch dataTypeImportResult.result { case .success: return false case .failure: @@ -422,7 +528,7 @@ extension DataImportViewModel { for await event in importTask.progress { switch event { case .progress(let update): - os_log(.debug, log: .dataImportExport, "progress: \(update)") + log("progress: \(update)") return .progress(update) // on completion returns new DataImportViewModel with merged import summary case .completed(.success(let summary)): @@ -434,10 +540,29 @@ extension DataImportViewModel { } enum ButtonType: Hashable { - case next(Screen), initiateImport, skip, cancel, back, done, submit + case next(Screen) + case initiateImport(disabled: Bool) + case skip + case cancel + case back + case done + case submit + + var isDisabled: Bool { + switch self { + case .initiateImport(disabled: let disabled): + return disabled + case .next, .skip, .done, .cancel, .back, .submit: + return false + } + } } @MainActor var actionButton: ButtonType? { + func initiateImport() -> ButtonType { + .initiateImport(disabled: selectedDataTypes.isEmpty || importTask != nil) + } + switch screen { case .profileAndDataTypesPicker: guard let importer = selectedProfile.map({ @@ -451,85 +576,65 @@ extension DataImportViewModel { // or selected data type not supported by selected browser data importer guard let type = DataType.allCases.filter(selectedDataTypes.contains).first else { // disabled Import button - return .initiateImport + return initiateImport() } // use CSV/HTML file import - return .next(.fileImport(type)) + return .next(.fileImport(dataType: type)) } if importer.requiresKeychainPassword(for: selectedDataTypes) { return .next(.moreInfo) } - return .initiateImport + return initiateImport() - case .moreInfo, .getReadPermission: - return .initiateImport + case .moreInfo: + return initiateImport() - case .fileImport: - if case .fileImport = importSource.initialScreen { - return nil - } else if case .summary = nextScreen(skip: true) { - return secondaryButton == .back ? .cancel : nil - } + case .getReadPermission: + return .initiateImport(disabled: true) + + case .fileImport where screen == importSource.initialScreen: + // no default action for File Import sources + return nil + case .fileImport(dataType: let dataType, summary: _) where selectedDataTypes.subtracting([dataType]).isEmpty: + // no other data types to skip: + return .cancel + case .fileImport, .noData: return .skip - case .fileImportSummary: - if case .summary = nextScreen() { + case .summary(let dataTypes): + if let screen = screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: dataTypes.contains)) { + return .next(screen) + } else { return .done } - return .next(nextScreen()) - - case .summary: - return .done case .feedback: return .submit } } - @MainActor var isActionButtonDisabled: Bool { - guard importTask == nil else { return true } - - switch actionButton { - case .next: - return false - case .initiateImport: - if case .getReadPermission = screen { - return true - } else if selectedDataTypes.isEmpty { - return true - } - default: break - } - return false - } - var secondaryButton: ButtonType? { if importTask == nil { switch screen { case importSource.initialScreen, .feedback: return .cancel - default: + case .moreInfo: return .back + default: + return nil } } else { return .cancel } } - var isSecondaryButtonDisabled: Bool { - false - } var isSelectFileButtonDisabled: Bool { importTask != nil } - @MainActor - var buttons: [(type: ButtonType, isDisabled: Bool)] { - [ - secondaryButton.map { (type: $0, isDisabled: isSecondaryButtonDisabled) }, - actionButton.map { (type: $0, isDisabled: isActionButtonDisabled) }, - ].compactMap { $0 } + @MainActor var buttons: [ButtonType] { + [secondaryButton, actionButton].compactMap { $0 } } mutating func update(with importSource: Source) { @@ -538,16 +643,7 @@ extension DataImportViewModel { @MainActor mutating func performAction(for buttonType: ButtonType, dismiss: @escaping () -> Void) { - let dismissView = { [summary] in - // send `bookmarkPromptShouldShow` notification after dismiss if at least one bookmark was imported - if summary.reduce(into: 0, { $0 += $1.dataType == .bookmarks ? (try? $1.result.get().successful) ?? 0 : 0 }) > 0 { - DispatchQueue.main.async { - NotificationCenter.default.post(name: .bookmarkPromptShouldShow, object: nil) - } - } - - dismiss() - } + assert(buttons.contains(buttonType)) switch buttonType { case .next(let screen): @@ -557,19 +653,33 @@ extension DataImportViewModel { case .initiateImport: initiateImport() + case .skip: skipImport() case .cancel: - // TODO: cancel importer adding to database on Task cancel importTask?.cancel() - dismissView() + self.dismiss(using: dismiss) case .submit: submitReport() - dismissView() + self.dismiss(using: dismiss) case .done: - dismissView() + self.dismiss(using: dismiss) + } + } + + private mutating func dismiss(using dismiss: @escaping () -> Void) { + // send `bookmarkPromptShouldShow` notification after dismiss if at least one bookmark was imported + if summary.reduce(into: 0, { $0 += $1.dataType == .bookmarks ? (try? $1.result.get().successful) ?? 0 : 0 }) > 0 { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .bookmarkPromptShouldShow, object: nil) + } + } + + dismiss() + if case .xcPreviews = NSApp.runType { + self.update(with: importSource) // reset } } diff --git a/DuckDuckGo/DataImport/View/DataImportErrorView.swift b/DuckDuckGo/DataImport/View/DataImportErrorView.swift deleted file mode 100644 index 09c10c9257..0000000000 --- a/DuckDuckGo/DataImport/View/DataImportErrorView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// DataImportErrorView.swift -// -// Copyright © 2023 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation diff --git a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift new file mode 100644 index 0000000000..8cf6152de0 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift @@ -0,0 +1,45 @@ +// +// DataImportNoDataView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +struct DataImportNoDataView: View { + + let source: DataImport.Source + let dataType: DataImport.DataType + let manualImportAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("We couldn‘t find any \(dataType.displayName)…", comment: "Data import error message: Bookmarks or Passwords (%@) weren‘t found.") + + Text("You could try importing \(dataType.displayName) manually.", + comment: "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file.") + + Button(UserText.manualImport, action: manualImportAction) + } + } + +} + +#Preview { + DataImportNoDataView(source: .chrome, dataType: .bookmarks) { print("Manual Import") } + .frame(width: 512 - 20) + .padding() +} diff --git a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift index 16b88a0b92..b2c2c03c08 100644 --- a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift @@ -23,13 +23,16 @@ struct DataImportSourcePicker: View { @State private var viewModel: DataImportSourceViewModel + private let onSelectedSourceChanged: (DataImport.Source) -> Void + private var importSources: [DataImport.Source?] { viewModel.importSources } init(selectedSource: DataImport.Source, onSelectedSourceChanged: @escaping (DataImport.Source) -> Void) { - self.viewModel = DataImportSourceViewModel(selectedSource: selectedSource, onSelectedSourceChanged: onSelectedSourceChanged) + self.viewModel = DataImportSourceViewModel(selectedSource: selectedSource) + self.onSelectedSourceChanged = onSelectedSourceChanged } var body: some View { @@ -51,7 +54,7 @@ struct DataImportSourcePicker: View { .controlSize(.large) .onChange(of: viewModel.selectedSourceIndex) { idx in guard let importSource = importSources[idx] else { return } - viewModel.onSelectedSourceChanged(importSource) + onSelectedSourceChanged(importSource) } } diff --git a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift index 59775ae5bb..580a22eeca 100644 --- a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift +++ b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift @@ -23,50 +23,79 @@ struct DataImportSummaryView: View { typealias DataType = DataImport.DataType typealias Summary = DataImport.DataTypeSummary - let summary: [DataType: Summary] + let model: DataImportSummaryViewModel - init(summary: [DataType: Summary]) { - self.summary = summary + init(_ importViewModel: DataImportViewModel, dataTypes: Set? = nil) { + self.init(model: .init(source: importViewModel.importSource, results: importViewModel.summary, dataTypes: dataTypes)) + } + + init(model: DataImportSummaryViewModel) { + self.model = model } var body: some View { VStack(alignment: .leading, spacing: 8) { - ForEach(DataType.allCases.compactMap { - guard let result = summary[$0] else { return nil } - return (dataType: $0, result: result) - }, id: \.dataType) { (item: (dataType: DataType, result: Summary)) in - switch item.dataType { - case .bookmarks: + { + switch model.summaryKind { + case .results, .importComplete(.passwords): + Text("Import Results:", comment: "Data Import result summary headline") + + case .importComplete(.bookmarks), + .fileImportComplete(.bookmarks): + Text("Bookmarks Import Complete:", comment: "Bookmarks Data Import result summary headline") + + case .fileImportComplete(.passwords): + Text("Password import complete. You can now delete the saved passwords file.", comment: "message about Passwords Data Import completion") + } + }().padding(.bottom, 16) + + ForEach(model.results, id: \.dataType) { item in + switch (item.dataType, item.result) { + case (.bookmarks, .success(let summary)): HStack { successImage() - Text("Bookmarks: \(item.result.successful)", + Text("Bookmarks: \(summary.successful)", comment: "Data import summary format of how many bookmarks (%lld) were successfully imported.") } - if item.result.duplicate > 0 { + if summary.duplicate > 0 { HStack { failureImage() - Text("Duplicate Bookmarks Skipped: \(item.result.duplicate)", + Text("Duplicate Bookmarks Skipped: \(summary.duplicate)", comment: "Data import summary format of how many duplicate bookmarks (%lld) were skipped during import.") } } - if item.result.failed > 0 { + if summary.failed > 0 { HStack { failureImage() - Text("Bookmark import failed: \(item.result.failed)", + Text("Bookmark import failed: \(summary.failed)", comment: "Data import summary format of how many bookmarks (%lld) failed to import.") } } - case .passwords: + case (.bookmarks, .failure): + HStack { + failureImage() + Text("Bookmark import failed.", + comment: "Data import summary message of failed bookmarks import.") + } + + case (.passwords, .failure): + HStack { + failureImage() + Text("Passwords import failed.", + comment: "Data import summary message of failed passwords import.") + } + + case (.passwords, .success(let summary)): HStack { successImage() - Text("Passwords: \(item.result.successful)", + Text("Passwords: \(summary.successful)", comment: "Data import summary format of how many passwords (%lld) were successfully imported.") } - if item.result.failed > 0 { + if summary.failed > 0 { HStack { failureImage() - Text("Passwords import failed: \(item.result.failed)", + Text("Passwords import failed: \(summary.failed)", comment: "Data import summary format of how many passwords (%lld) failed to import.") } } @@ -88,10 +117,12 @@ private func failureImage() -> some View { } #Preview { - DataImportSummaryView(summary: [ - .bookmarks: .init(successful: 123, duplicate: 456, failed: 7890), - .passwords: .init(successful: 123, duplicate: 456, failed: 7890) - ]) - .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + VStack { + DataImportSummaryView(model: .init(source: .chrome, summary: [ + .bookmarks: .success(.init(successful: 123, duplicate: 456, failed: 7890)), + .passwords: .success(.init(successful: 123, duplicate: 456, failed: 7890)) + ])) + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + } .frame(width: 512) } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 741abbf5ed..d71a26f931 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -44,18 +44,21 @@ struct DataImportView: View { @Environment(\.dismiss) private var dismiss - @State var viewModel = DataImportViewModel() + @State var model = DataImportViewModel() @State private var progressText: String? @State private var progressFraction: Double? +#if DEBUG || REVIEW + @State private var debugViewDisabled: Bool = false +#endif + var body: some View { VStack(alignment: .leading, spacing: 0) { viewHeader() .padding(.top, 20) .padding(.leading, 20) .padding(.trailing, 20) - .padding(.bottom, 24) viewBody() .padding(.leading, 20) @@ -68,6 +71,12 @@ struct DataImportView: View { .padding(.top, 16) .padding(.bottom, 16) .padding(.trailing, 20) + +#if DEBUG || REVIEW + if !debugViewDisabled { + debugView() + } +#endif } .frame(width: 512) .fixedSize() @@ -80,47 +89,56 @@ struct DataImportView: View { .padding(.bottom, 16) // browser to import data from picker popup - if case .feedback = viewModel.screen {} else { - DataImportSourcePicker(selectedSource: viewModel.importSource) { importSource in - viewModel.update(with: importSource) + if case .feedback = model.screen {} else { + DataImportSourcePicker(selectedSource: model.importSource) { importSource in + model.update(with: importSource) } - .disabled(viewModel.isImportSourcePickerDisabled) + .disabled(model.isImportSourcePickerDisabled) + .padding(.bottom, 24) } } } - // swiftlint:disable:next function_body_length private func viewBody() -> some View { VStack(alignment: .leading, spacing: 0) { // body - switch viewModel.screen { + switch model.screen { case .profileAndDataTypesPicker: // Browser Profile picker - DataImportProfilePicker(profileList: viewModel.browserProfiles, - selectedProfile: $viewModel.selectedProfile) - .disabled(viewModel.isImportSourcePickerDisabled) + DataImportProfilePicker(profileList: model.browserProfiles, + selectedProfile: $model.selectedProfile) + .disabled(model.isImportSourcePickerDisabled) .padding(.bottom, 24) // Bookmarks/Passwords checkboxes - DataImportTypePicker(viewModel: $viewModel) - .disabled(viewModel.isImportSourcePickerDisabled) + DataImportTypePicker(viewModel: $model) + .disabled(model.isImportSourcePickerDisabled) case .moreInfo: // you will be asked for your keychain password blah blah... - BrowserImportMoreInfoView(source: viewModel.importSource) + BrowserImportMoreInfoView(source: model.importSource) case .getReadPermission(let url): // give request to Safari folder, select Bookmarks.plist using open panel - RequestFilePermissionView(source: viewModel.importSource, url: url, requestDataDirectoryPermission: SafariDataImporter.requestDataDirectoryPermission) { _ in + RequestFilePermissionView(source: model.importSource, url: url, requestDataDirectoryPermission: SafariDataImporter.requestDataDirectoryPermission) { _ in + + model.initiateImport() + } - viewModel.initiateImport() + case .noData(dataType: let dataType): + // no #dataType imported [skip], [manual import] + DataImportNoDataView(source: model.importSource, dataType: dataType) { + model.manualImport(dataType: dataType) } - case .fileImport(let dataType): + case .fileImport(let dataType, summary: let summaryTypes): + if !summaryTypes.isEmpty { + DataImportSummaryView(model, dataTypes: summaryTypes) + // if browser importer failed - display error message - if viewModel.hasDataTypeImportFailed(dataType) { - Text("We were unable to import directly from \(viewModel.importSource.importSourceName).", - comment: "Message when data import fails from a browser. %@ - a browser name") + } else if model.hasDataTypeImportFailed(dataType) { + Text("We were unable to import \(dataType.displayName) directly from \(model.importSource.importSourceName).", + comment: "Message when data import fails from a browser. %1$@ - Bookmarks or Passwords; %2$@ - a browser name") .font(.headline) Spacer().frame(height: 8) Text("Let’s try doing it manually. It won’t take long.", @@ -129,42 +147,24 @@ struct DataImportView: View { } // manual file import instructions for CSV/HTML - FileImportView(source: viewModel.importSource, dataType: dataType, isButtonDisabled: viewModel.isSelectFileButtonDisabled) { - viewModel.selectFile() + FileImportView(source: model.importSource, dataType: dataType, isButtonDisabled: model.isSelectFileButtonDisabled) { + model.selectFile() } onFileDrop: { url in - viewModel.initiateImport(fileURL: url) + model.initiateImport(fileURL: url) } - case .fileImportSummary(let dataType): - // present file impoter import summary for one data type - Text("\(dataType.displayName) Import Complete", - comment: "Passwords or Bookmarks (%@) File Data Import completion message") - .font(.headline) - Spacer().frame(height: 12) - DataImportSummaryView(summary: ( - try? viewModel.summary.last(where: { - $0.dataType == dataType - })?.result.get() - ).map { [dataType: $0] } ?? [:]) - - case .summary: - // total import summary - Text("Import Complete", - comment: "Browser data import completion message") - .font(.headline) - Spacer().frame(height: 12) - - // import completed - DataImportSummaryView(summary: viewModel.summary.reduce(into: [:]) { - $0[$1.dataType] = try? $1.result.get() - }) + case .summary(let dataTypes): + DataImportSummaryView(model, dataTypes: dataTypes) case .feedback: - ReportFeedbackView(model: $viewModel.reportModel) + DataImportSummaryView(model) + .padding(.bottom, 20) + + ReportFeedbackView(model: $model.reportModel) } // Import in progress… - if let importProgress = viewModel.importProgress { + if let importProgress = model.importProgress { progressView(importProgress) } } @@ -177,7 +177,7 @@ struct DataImportView: View { } .padding(.top, 24) .task { - // when viewModel.importProgress async sequence not nil + // when model.importProgress async sequence not nil // receive progress updates events and update model on completion await handleImportProgress(progress) } @@ -188,14 +188,14 @@ struct DataImportView: View { HStack(spacing: 8) { Spacer() - ForEach(viewModel.buttons, id: \.type) { button in + ForEach(model.buttons, id: \.self) { button in Button { - viewModel.performAction(for: button.type, dismiss: dismiss.callAsFunction) + model.performAction(for: button, dismiss: dismiss.callAsFunction) } label: { - Text(button.type.title) + Text(button.title) .frame(minWidth: 80 - 16 - 1) } - .keyboardShortcut(button.type.shortcut) + .keyboardShortcut(button.shortcut) .disabled(button.isDisabled) } } @@ -211,13 +211,66 @@ struct DataImportView: View { progressText = progress.description progressFraction = progress.fraction - // update view model on completion - case .completed(.success(let viewModel)): - self.viewModel = viewModel + // update view model on completion + case .completed(.success(let newModel)): + self.model = newModel } } } +#if DEBUG || REVIEW + private func debugView() -> some View { + + VStack(alignment: .leading, spacing: 10) { + Divider() + + HStack { + Text("REVIEW:" as String).bold() + .padding(.top, 10) + .padding(.leading, 20) + Spacer() + if case .normal = NSApp.runType { + Button("⛌" as String) { debugViewDisabled.toggle() } + .buttonStyle(.borderless) + .padding(.trailing, 20) + } + } + + ForEach(DataImport.DataType.allCases.filter(model.selectedDataTypes.contains), id: \.self) { selectedDataType in + failureReasonPicker(for: selectedDataType) + .padding(.leading, 20) + .padding(.trailing, 20) + } + } + .padding(.bottom, 10) + .background(Color(NSColor(red: 1, green: 0, blue: 0, alpha: 0.2))) + } + + private var noFailure: String { "No failure" } + private var allFailureReasons: [String?] { + [noFailure, nil] + DataImport.ErrorType.allCases.map { $0.rawValue } + } + + private func failureReasonPicker(for dataType: DataImport.DataType) -> some View { + Picker(selection: Binding { + allFailureReasons.firstIndex(of: model.testImportFailureReasons[dataType]?.rawValue ?? noFailure)! + } set: { newValue in + model.testImportFailureReasons[dataType] = DataImport.ErrorType(rawValue: allFailureReasons[newValue]!) + }) { + ForEach(allFailureReasons.indices, id: \.self) { idx in + if let failureReason = allFailureReasons[idx] { + Text(failureReason) + } else { + Divider() + } + } + } label: { + Text("\(dataType.displayName) import error:" as String) + .frame(width: 150, alignment: .leading) + } + } +#endif + } extension DataImportProgressEvent { @@ -316,6 +369,7 @@ extension DataImportViewModel.ButtonType { } return nil } + var errorType: DataImport.ErrorType { .noData } case err(Error) } @@ -403,7 +457,7 @@ extension DataImportViewModel.ButtonType { || (type == .passwords && PreviewPreferences.shared.shouldPasswordsImportFail) { result[type] = .failure(ImportError.err(MockError())) } else { - result[type] = .success(.init(successful: Int.random(in: 0..<100000), duplicate: Int.random(in: 0..<100000), failed: Int.random(in: 0..<100000))) + result[type] = .success(.init(successful: Int.random(in: 0..<100000), duplicate: 0, failed: 0)) } } return result @@ -450,27 +504,28 @@ extension DataImportViewModel.ButtonType { var body: some View { VStack(alignment: .leading, spacing: 10) { - Toggle("Bookmarks import should fail", isOn: $prefs.shouldBookmarkImportFail) - Toggle("Passwords import should fail", isOn: $prefs.shouldPasswordsImportFail) - Toggle("Display progress", isOn: $prefs.shouldDisplayProgress) + HStack { + Toggle("Display progress", isOn: $prefs.shouldDisplayProgress) + .padding(.leading, 20) + .padding(.bottom, 20) + Spacer() + } } - .padding(EdgeInsets(top: 0, leading: 20, bottom: 10, trailing: 10)) + .frame(width: 512) + .background(Color(NSColor(red: 1, green: 0, blue: 0, alpha: 0.2))) } } - return VStack { - DataImportView(viewModel: viewModel) + return VStack(alignment: .leading, spacing: 0) { + DataImportView(model: viewModel) // swiftlint:disable:next force_cast .environment(\EnvironmentValues.presentationMode as! WritableKeyPath, - Binding { print("DISMISS!") }) - - VStack(alignment: .leading, spacing: 10) { - Spacer() - Divider().frame(width: 512) - PreviewPreferencesView() + Binding { + print("DISMISS!") + }) - }.background(Color(NSColor(red: 1, green: 0, blue: 0, alpha: 0.3))) + PreviewPreferencesView() } - .frame(minHeight: 500) + .frame(minHeight: 666) }() } diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index f664415e3c..8e43de1afd 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -624,7 +624,7 @@ struct CircleNumberView: View { // MARK: - Preview #Preview { - FileImportView(source: .bitwarden, dataType: .passwords, isButtonDisabled: false) + FileImportView(source: .chrome, dataType: .passwords, isButtonDisabled: false) .frame(width: 512 - 20) } diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift index 92ede55ec5..4e98d04372 100644 --- a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -22,14 +22,6 @@ struct ReportFeedbackView: View { @Binding var model: DataImportReportModel - private var title: LocalizedStringKey { - if model.retryNumber <= 1 { - "Please submit a report to help us fix the issue." - } else { - "That didn’t work either. Please submit a report to help us fix the issue." - } - } - var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -41,21 +33,29 @@ struct ReportFeedbackView: View { Text("That didn’t work either. Please submit a report to help us fix the issue.", comment: "Data import failure Report dialog title containing a message that not only automatic data import has failed failed but manual browser data import didn‘t work either.") } - }().font(.headline) - - Spacer().frame(height: 8) + }() + .font(.headline) + .padding(.bottom, 8) VStack(alignment: .leading, spacing: 12) { Text(""" The following information will be sent to DuckDuckGo. No personally identifiable information will be sent. """, comment: "Data import failure Report dialog subtitle about the data being collected with the report.") - InfoItemView("macOS version", model.osVersion) - InfoItemView("DuckDuckGo browser version", model.appVersion) - InfoItemView("The version of the browser you are trying to import from", model.importSourceDescription) - InfoItemView("Error message & code", model.error.localizedDescription) + InfoItemView(model.osVersion) { + Text("macOS version", comment: "Data import failure Report dialog description of a report field providing user‘s macOS version") + } + InfoItemView(model.appVersion) { + Text("DuckDuckGo browser version", comment: "Data import failure Report dialog description of a report field providing current DuckDuckGo Browser version") + } + InfoItemView(model.importSourceDescription) { + Text("The version of the browser you are trying to import from", comment: "Data import failure Report dialog description of a report field providing version of a browser user is trying to import data from") + } + InfoItemView(model.error.localizedDescription) { + Text("Error message & code", comment: "") + } } - Spacer().frame(height: 24) + .padding(.bottom, 24) ZStack(alignment: .top) { EditableTextView(text: $model.text, @@ -82,11 +82,11 @@ struct ReportFeedbackView: View { private struct InfoItemView: View { - let text: LocalizedStringKey + let text: () -> Text let data: String @State private var isPopoverVisible = false - init(_ text: LocalizedStringKey, _ data: String) { + init(_ data: String, text: @escaping () -> Text) { self.text = text self.data = data } @@ -103,7 +103,7 @@ private struct InfoItemView: View { Text(data).padding() } - Text(text) + text() } } @@ -127,6 +127,7 @@ private struct InfoItemView: View { } static var errorDomain: String { "ReportFeedbackPreviewError" } + var errorType: DataImport.ErrorType { .noData } case err(Error) } diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 695d0864f7..d551029c18 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -8,6 +8,7 @@ }, "%@ does not support storing %@" : { + "comment" : "Data Import disabled checkbox message about a browser (%1$@) not supporting storing a data type (%2$@ - Bookmarks or Passwords)", "localizations" : { "en" : { "stringUnit" : { @@ -16,9 +17,6 @@ } } } - }, - "%@ Import Complete" : { - }, "%lld" : { @@ -72,7 +70,7 @@ "comment" : "Main Menu View item" }, "Add any details that you think may help us fix the problem" : { - + "comment" : "Data import failure Report dialog suggestion to provide a comments with extra details helping to identify the data import problem." }, "add.favorite" : { "comment" : "Button for adding a favorite bookmark", @@ -1058,7 +1056,10 @@ } }, "Bookmark import failed: %lld" : { - + "comment" : "Data import summary format of how many bookmarks (%lld) failed to import." + }, + "Bookmark import failed." : { + "comment" : "Data import summary message of failed bookmarks import." }, "bookmark.page" : { "comment" : "Context menu item", @@ -1108,11 +1109,11 @@ } } }, - "Bookmarks import should fail" : { - + "Bookmarks Import Complete:" : { + "comment" : "Bookmarks Data Import result summary headline" }, "Bookmarks: %lld" : { - + "comment" : "Data import summary format of how many bookmarks (%lld) were successfully imported." }, "Bookmarks…" : { "comment" : "Main Menu File-Export item" @@ -2414,12 +2415,13 @@ } }, "DuckDuckGo browser version" : { - + "comment" : "Data import failure Report dialog description of a report field providing current DuckDuckGo Browser version" }, "DuckDuckGo Help" : { "comment" : "Main Menu Help item" }, "DuckDuckGo needs your permission to read the %@ bookmarks file. Select the %@ folder to import bookmarks." : { + "comment" : "Data import warning that DuckDuckGo browser requires file reading permissions for another browser name (%1$@), and instruction to select its (same browser name - %2$@) bookmarks folder.", "localizations" : { "en" : { "stringUnit" : { @@ -2430,7 +2432,7 @@ } }, "Duplicate Bookmarks Skipped: %lld" : { - + "comment" : "Data import summary format of how many duplicate bookmarks (%lld) were skipped during import." }, "duplicate.tab" : { "comment" : "Menu item. Duplicate as a verb", @@ -3382,7 +3384,7 @@ } }, "If your computer prompts you to enter a password prior to import, DuckDuckGo will not see that password.\n\nImported passwords are stored securely using encryption." : { - + "comment" : "Warning that Chromium data import would require entering system passwords." }, "Import Bookmarks" : { @@ -3392,13 +3394,13 @@ }, "Import Browser Data" : { - }, - "Import Complete" : { - }, "Import Passwords" : { "comment" : "my comment" }, + "Import Results:" : { + "comment" : "Data Import result summary headline" + }, "import.bookmarks.bookmarks" : { "comment" : "Title text for the Bookmarks import option", "extractionState" : "extracted_with_value", @@ -3459,6 +3461,18 @@ } } }, + "import.browser.data" : { + "comment" : "Import Browser Data dialog title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Browser Data" + } + } + } + }, "import.browser.data.bookmarks" : { "comment" : "Opens Import Browser Data dialog", "extractionState" : "extracted_with_value", @@ -3639,26 +3653,26 @@ } } }, - "import.data.import-failed.title" : { - "comment" : "Alert title when the data import fails", + "import.data.initiate" : { + "comment" : "Button text for importing data", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Sorry, we weren't able to import your data." + "value" : "Import" } } } }, - "import.data.initiate" : { - "comment" : "Button text for importing data", + "import.data.manual" : { + "comment" : "Button text for initiating manual data import using a HTML or CSV file when automatic import has failed", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Import" + "value" : "Manual import…" } } } @@ -3847,10 +3861,7 @@ } }, "Let’s try doing it manually. It won’t take long." : { - - }, - "Login import failed: %lld" : { - + "comment" : "Suggestion to switch to a Manual File Data Import when data import fails." }, "looking.for.bitwarden" : { "comment" : "Setup of the integration with Bitwarden app", @@ -3865,7 +3876,7 @@ } }, "macOS version" : { - + "comment" : "Data import failure Report dialog description of a report field providing user‘s macOS version" }, "main.menu.close.downloads" : { "comment" : "Hide Downloads Popover", @@ -5759,6 +5770,9 @@ } } }, + "Password import complete. You can now delete the saved passwords file." : { + "comment" : "message about Passwords Data Import completion" + }, "password.manager" : { "comment" : "Section header", "extractionState" : "extracted_with_value", @@ -5771,11 +5785,14 @@ } } }, - "Passwords import should fail" : { - + "Passwords import failed: %lld" : { + "comment" : "Data import summary format of how many passwords (%lld) failed to import." + }, + "Passwords import failed." : { + "comment" : "Data import summary message of failed passwords import." }, "Passwords: %lld" : { - + "comment" : "Data import summary format of how many passwords (%lld) were successfully imported." }, "Passwords…" : { "comment" : "Main Menu File-Export item" @@ -6195,7 +6212,7 @@ } }, "Please submit a report to help us fix the issue." : { - + "comment" : "Data import failure Report dialog title." }, "pm.activate" : { "comment" : "Activate button", @@ -7773,7 +7790,7 @@ }, "Select data to import:" : { - + "comment" : "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks." }, "Select LastPass CSV File…" : { @@ -7782,7 +7799,7 @@ }, "Select Profile:" : { - + "comment" : "Browser Profile picker title for Data Import" }, "select.bitwarden.preferences" : { "comment" : "Setup of the integration with Bitwarden app (up to and including macOS 12)", @@ -8049,13 +8066,13 @@ "comment" : "Main Menu Edit-Substitutions item" }, "That didn’t work either. Please submit a report to help us fix the issue." : { - + "comment" : "Data import failure Report dialog title containing a message that not only automatic data import has failed failed but manual browser data import didn‘t work either." }, "The following information will be sent to DuckDuckGo. No personally identifiable information will be sent." : { - + "comment" : "Data import failure Report dialog subtitle about the data being collected with the report." }, "The version of the browser you are trying to import from" : { - + "comment" : "Data import failure Report dialog description of a report field providing version of a browser user is trying to import data from" }, "tooltip.addToFavorites" : { "comment" : "Tooltip for add to favorites button", @@ -8425,8 +8442,19 @@ "View" : { "comment" : "Main Menu View" }, - "We were unable to import directly from %@." : { - + "We couldn‘t find any %@…" : { + "comment" : "Data import error message: Bookmarks or Passwords (%@) weren‘t found." + }, + "We were unable to import %@ directly from %@." : { + "comment" : "Message when data import fails from a browser. %1$@ - Bookmarks or Passwords; %2$@ - a browser name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We were unable to import %1$@ directly from %2$@." + } + } + } }, "web.tracking.protection.explenation" : { "comment" : "feature explanation in settings", @@ -8455,8 +8483,11 @@ "Window" : { "comment" : "Main Menu " }, + "You could try importing %@ manually." : { + "comment" : "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file." + }, "You'll be asked to enter your Primary Password for %@.\n\nImported passwords are encrypted and only stored on this computer." : { - + "comment" : "Warning that Firefox-based browser name (%@) data import would require entering a Primary Password for the browser." }, "zoom" : { "comment" : "Menu with Zooming commands", @@ -8478,4 +8509,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/DuckDuckGo/Statistics/PixelArguments.swift b/DuckDuckGo/Statistics/PixelArguments.swift index 9361c6eb4f..0603d21f35 100644 --- a/DuckDuckGo/Statistics/PixelArguments.swift +++ b/DuckDuckGo/Statistics/PixelArguments.swift @@ -117,7 +117,7 @@ extension DataImportAction: CustomStringConvertible { var description: String { switch self { case .bookmarks: return "bookmarks" - case .logins: return "logins" + case .passwords: return "logins" case .favicons: return "favicons" case .generic: return "generic" } diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index f3b8ceed82..6e274a9e39 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -20,7 +20,7 @@ import Foundation import XCTest @testable import DuckDuckGo_Privacy_Browser -class BrowserProfileListTests: XCTestCase { +class BrowserProfileTests: XCTestCase { let mockURL = URL(string: "/Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/")! diff --git a/UnitTests/DataImport/DataImportSourceViewModelTests.swift b/UnitTests/DataImport/DataImportSourceViewModelTests.swift new file mode 100644 index 0000000000..54aa45dd7d --- /dev/null +++ b/UnitTests/DataImport/DataImportSourceViewModelTests.swift @@ -0,0 +1,69 @@ +// +// DataImportSourceViewModelTests.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class DataImportSourceViewModelTests: XCTestCase { + + func testImportSourcesContainsMandatorySources() { + let model = DataImportSourceViewModel(selectedSource: .csv) + + XCTAssertEqual(model.importSources.compactMap { $0 }, DataImport.Source.allCases.filter(\.canImportData)) + + XCTAssertTrue(model.importSources.contains(.safari)) + XCTAssertTrue(model.importSources.contains(.bitwarden)) + XCTAssertTrue(model.importSources.contains(.onePassword8)) + XCTAssertTrue(model.importSources.contains(.lastPass)) + XCTAssertTrue(model.importSources.contains(.csv)) + XCTAssertTrue(model.importSources.contains(.bookmarksHTML)) + + XCTAssertEqual(model.selectedSourceIndex, model.importSources.firstIndex(of: .csv)) + } + + func testSeparatorsBeforeOnePasswordAndCSVImportArePresent() { + let model = DataImportSourceViewModel(importSources: [ + .chrome, + .bitwarden, + .csv, + .bookmarksHTML, + .onePassword8, + .onePassword7, + .lastPass, + ], selectedSource: .bitwarden) + + XCTAssertEqual(model.importSources, [ + .chrome, + .bitwarden, + nil, + .csv, + .bookmarksHTML, + nil, + .onePassword8, + .onePassword7, + .lastPass, + ]) + } + + func testWhenUnavailableSelectedSourcePassed_selectedSourceIndexIs0() { + let model = DataImportSourceViewModel(importSources: [.csv], selectedSource: .bitwarden) + XCTAssertEqual(model.selectedSourceIndex, 0) + } + +} diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift new file mode 100644 index 0000000000..2f3c2d6b82 --- /dev/null +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -0,0 +1,648 @@ +// +// DataImportViewModelTests.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import Foundation +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +@MainActor +final class DataImportViewModelTests: XCTestCase { + + typealias Source = DataImport.Source + typealias BrowserProfileList = DataImport.BrowserProfileList + typealias BrowserProfile = DataImport.BrowserProfile + typealias DataType = DataImport.DataType + + var model: DataImportViewModel! + + override func setUp() { + model = nil + importTask = nil + + // TODO: remove me + OSLog.loggingCategories.insert(OSLog.AppCategories.dataImportExport.rawValue) + } + + func setupModel(with source: Source, profiles: [(ThirdPartyBrowser) -> BrowserProfile], screen: DataImportViewModel.Screen? = nil, summary: DataImportViewModel.DataImportViewSummary = .init()) { + model = DataImportViewModel(importSource: source, screen: screen, summary: summary, loadProfiles: { browser in + .init(browser: browser, profiles: profiles.map { $0(browser) }) { profile in + { + // TODO: unavailability; test invalid profiles + .init(logins: .available, bookmarks: .available) + } + } + }, dataImporterFactory: dataImporter) + } + + func selectProfile(_ profile: BrowserProfile, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(model.browserProfiles?.validImportableProfiles.contains(profile) == true, message().with("profile"), file: file, line: line) + model.selectedProfile = profile + } + + func initiateImport(of dataTypes: Set, from profile: BrowserProfile? = nil, fromFile url: URL? = nil, resultingWith getResult: @escaping @autoclosure () -> DataImportSummary, file: StaticString = #filePath, line: UInt = #line, progress progressUpdateCallback: ((DataImportProgressEvent) -> Void)? = nil) async throws { + assert((profile != nil) != (url != nil), "must provide either profile or url") + + let source = model.importSource + let message: () -> String = { "\(source): \(profile?.profileName ?? url!.path)" } + + for dataType in DataType.allCases where model.selectedDataTypes.contains(dataType) != dataTypes.contains(dataType) { + model.setDataType(dataType, selected: dataTypes.contains(dataType)) + } + XCTAssertEqual(model.selectedDataTypes, dataTypes, message().with("selectedDataTypes"), file: file, line: line) + + if let profile { + selectProfile(profile, message(), file: file, line: line) + } + + self.importTask = { _ in getResult() } +// TODO: test cancel/back + var model: DataImportViewModel = self.model + if let url { + XCTAssertEqual(model.actionButton, .initiateImport(disabled: true), message().with("actionButton"), file: file, line: line) + model.initiateImport(fileURL: url) + } else { + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false), message().with("actionButton"), file: file, line: line) + model.performAction(.initiateImport(disabled: false)) + } + self.model = model + + struct NoProgress: Error {} + guard let importProgress = model.importProgress else { XCTAssertNotNil(model.importProgress, message().with("importProgress"), file: file, line: line); throw NoProgress() } + + let taskStarted = expectation(description: "import task started") + let taskCompleted = expectation(description: "import task completed") + + let task = Task { + taskStarted.fulfill() + + for await event in importProgress { + switch event { + case .progress(let progressEvent): + progressUpdateCallback?(progressEvent) + case .completed(.success(let newModel)): + taskCompleted.fulfill() + + return newModel + } + } + return nil + } + + await fulfillment(of: [taskStarted, taskCompleted], timeout: 0.5) + + self.model = try await task.value ?? { throw CancellationError() }() + } + + func expect(_ screen: DataImportViewModel.Screen, actionButton: DataImportViewModel.ButtonType? = nil, secondaryButton: DataImportViewModel.ButtonType? = nil, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + + XCTAssertEqual(model.screen, screen, message().with("screen"), file: file, line: line) + XCTAssertEqual(model.actionButton, actionButton, message().with("actionButton"), file: file, line: line) + XCTAssertEqual(model.secondaryButton, secondaryButton, message().with("secondaryButton"), file: file, line: line) + XCTAssertNil(model.importProgress, message().with("importProgress"), file: file, line: line) + + // auto test cancel/back/done + for button in Set([actionButton, secondaryButton]).intersection(model.buttons).compactMap({ $0 }) { + var model = model! + switch button { + case .cancel, .done: + let e = expectation(description: message().with("dismiss called")) + model.performAction(for: button, dismiss: { + e.fulfill() + }) + waitForExpectations(timeout: 0) + + case .back: + let initialSource = model.importSource + let initialProfiles = model.browserProfiles + let initialProfile = model.selectedProfile + let initialScreen = initialSource.initialScreen + model.performAction(button) + XCTAssertEqual(model.screen, initialScreen, message().with("Back - initialScreen"), file: file, line: line) + XCTAssertEqual(model.browserProfiles?.profiles, initialProfiles?.profiles, message().with("Back - initialProfiles"), file: file, line: line) + XCTAssertEqual(model.selectedProfile, initialProfile, message().with("Back - initialProfile"), file: file, line: line) + case .submit: + // TODO: test submit + fatalError() + default: + break + } + } + + // TODO: + } + + func testWhenBrowserPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) +// TODO: tor does not support passwords + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 10, duplicate: 0, failed: 0)), + .passwords: .failure(MockImportError(action: .passwords, errorType: .decryptionError)) + ]) + + expect(.error(dataType: .passwords, errorType: .decryptionError), actionButton: .manualImport, secondaryButton: .skip, "\(source)") + } + } + + func testWhenManualImportChosenForPasswords_csvFileImportScreenIsShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], screen: .error(dataType: .passwords, errorType: .decryptionError), summary: [ + .init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), + .init(.passwords, .failure(MockImportError(action: .passwords, errorType: .decryptionError))) + ]) + + model.performAction(.manualImport) + // TODO: tor does not support passwords + expect(.fileImport(.passwords), actionButton: .skip, secondaryButton: .back, "\(source)") + } + } + +// try await testBranch { +// try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [ +// .passwords: .success(.init(successful: 1, duplicate: 2, failed: 42)) // TODO: failure alternative +// ]) +// expect(.summary, actionButton: .done, secondaryButton: .done, "\(source) - Manual Import - Success") +// +// } alternative: { +// model.performAction(.skip) +// expect(.summary, actionButton: .done, secondaryButton: .back, "\(source) - Manual Import - Skip") +// } +// +// } alternative: { +// model.performAction(.skip) +// expect(.summary, actionButton: .done, secondaryButton: .back, "\(source) - Skip") +// } + + + func testBrowserDataImport() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)) + ]) + + expect(.error(dataType: .bookmarks, errorType: .noData), actionButton: .manualImport, secondaryButton: .skip, "\(source)") + } + } + + func testGenericDataImport() async { + for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil || source.initialScreen != .profileAndDataTypesPicker { + model = DataImportViewModel(importSource: source, loadProfiles: { XCTFail("Unexpected loadProfiles"); return .init(browser: $0, profiles: []) }, dataImporterFactory: dataImporter) + XCTAssertEqual(source.supportedDataTypes.count, 1) + expect(.fileImport(source.supportedDataTypes.first!), actionButton: .initiateImport(disabled: true), secondaryButton: .cancel) + + + } + } +// TODO: test skip + // MARK: - Tests + + func testWhenPreferredImportSourcesAvailable_firstPreferredSourceIsSelected() { + model = DataImportViewModel(availableImportSources: [.safari, .csv, .bitwarden], preferredImportSources: [.firefox, .chrome, .bitwarden, .safari]) + XCTAssertEqual(model.importSource, .bitwarden) + } + + func testWhenModelIsInstantiated_initialScreenIsShown() { + for source in Source.allCases { + model = DataImportViewModel(importSource: source) + XCTAssertEqual(model.screen, source.initialScreen, "\(source)") + } + } + +// func testWhenNextButtonIsClicked_screenForTheButtonIsShown() { +// model = DataImportViewModel(importSource: .safari) +// model.performAction(.next(.fileImport(.bookmarks))) +// XCTAssertEqual(model.screen, .fileImport(.bookmarks)) +// } +// +// // MARK: Browser profiles +// +// func testWhenNoProfilesAreLoaded_selectedProfileIsNil() { +// model = DataImportViewModel(importSource: .safari, loadProfiles: { source in +// XCTAssertEqual(source, .safari) +// return .init(browser: source, profiles: []) +// }) +// XCTAssertNil(model.selectedProfile) +// } +// +// func testWhenProfilesAreLoaded_DefaultProfileIsSelected() { +// model = DataImportViewModel(importSource: .firefox, loadProfiles: { source in +// XCTAssertEqual(source, .firefox) +// return .init(browser: source, profiles: [.test(for: source), .default(for: source)]) +// }) +// XCTAssertEqual(model.selectedProfile, .default(for: .firefox)) +// } +// +// func testWhenImportSourceChanged_AnotherDefaultProfileIsSelected() { +// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .default(for: $0) ]) }) +// model.update(with: .chromium) +// XCTAssertEqual(model.selectedProfile, .default(for: .chrome)) +// } +// +// func testWhenNoDefaultProfileIsLoaded_firstProfileIsSelected() { +// model = DataImportViewModel(importSource: .chrome, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .test2(for: $0) ]) }) +// XCTAssertEqual(model.selectedProfile, .test(for: .chrome)) +// } +// // TODO: test switching from all generic to all browser and from all browsers to all generic and from all browsers to all browsers and from all generics to all generics +// +// // MARK: Import from browser profile +// +// // MARK: Buttons +// // TODO: when importer.importableTypes does not contain as selected type next screen should be file import +// func testWhenProfilesAreLoadedAndImporterCanImportStraightAway_buttonActionsAreCancelAndImport() { +// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in +// ImporterMock() +// }) +// +// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.actionButton, .initiateImport) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPassword_buttonActionsAreCancelAndMoreInfo() { +// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in +// ImporterMock(keychainPasswordRequiredFor: [.passwords]) +// }) +// +// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.actionButton, .next(.moreInfo)) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPasswordButPasswordsDataTypeNotSelected_buttonActionsAreCancelAndImport() { +// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in +// ImporterMock(keychainPasswordRequiredFor: [.passwords]) +// }) +// model.setDataType(.passwords, selected: false) +// +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.actionButton, .initiateImport) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenNoBrowserForImportSource_buttonActionsAreCancelAndNone() { +// for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil { +// model = DataImportViewModel(importSource: source, loadProfiles: { +// XCTFail("Unexpected loadProfiles") +// return .init(browser: $0, profiles: [.test(for: $0)]) +// }) +// +// XCTAssertEqual(model.selectedDataTypes, source.supportedDataTypes, "\(source)") +// XCTAssertNil(model.actionButton) +// XCTAssertEqual(model.secondaryButton, .cancel, "\(source)") +// XCTAssertFalse(model.isSecondaryButtonDisabled, "\(source)") +// } +// } +// +// func testWhenNoProfilesAreLoaded_buttonActionsAreCancelAndProceedToFileImport() { +// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) +// +// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.actionButton, .next(.fileImport(.bookmarks))) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenNoProfilesAreLoadedAndBookmarksDataTypeUnselected_fileImportDataTypeChanges() { +// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) +// +// model.setDataType(.bookmarks, selected: false) +// +// XCTAssertEqual(model.selectedDataTypes, [.passwords]) +// XCTAssertEqual(model.actionButton, .next(.fileImport(.passwords))) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenPasswordsDataTypeUnselected_fileImportDataTypeChanges() { +// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) +// +// model.setDataType(.passwords, selected: false) +// +// XCTAssertEqual(model.selectedDataTypes, [.bookmarks]) +// XCTAssertEqual(model.actionButton, .next(.fileImport(.bookmarks))) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenNoDataTypeSelected_actionButtonDisabled() { +// model = DataImportViewModel(importSource: .safari) +// +// model.setDataType(.bookmarks, selected: false) +// model.setDataType(.passwords, selected: false) +// +// XCTAssertEqual(model.selectedDataTypes, []) +// XCTAssertEqual(model.actionButton, .initiateImport) +// XCTAssertTrue(model.isActionButtonDisabled) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenImportSourceChanges_selectedDataTypesAreReset() { +// model = DataImportViewModel(importSource: .safari) +// +// model.setDataType(.bookmarks, selected: false) +// model.setDataType(.passwords, selected: false) +// +// model.update(with: .brave) +// +// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.actionButton, .next(.fileImport(.bookmarks))) +// XCTAssertEqual(model.secondaryButton, .cancel) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenBookmarksImportFails_failureMessageIsShown() async throws { +// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: self.dataImporter) +// +// let model = try await initiateImport { _ in +// // TODO: test [:] +// // TODO: test bookmarks .success with 0 bookmarks +// return [.bookmarks: .failure(MockImportError(action: .bookmarks, errorType: .decryptionError)), +// .passwords: .failure(MockImportError(action: .passwords, errorType: .decryptionError))] +// } +// +// XCTAssertEqual(model.importSource, .safari) +// XCTAssertEqual(model.summary, [ +// .init(.bookmarks, .failure(MockImportError(action: .bookmarks))), +// .init(.passwords, .failure(MockImportError(action: .passwords))), +// ]) +// XCTAssertEqual(model.screen, .error(dataType: .bookmarks, errorType: .decryptionError)) +// XCTAssertEqual(model.actionButton, .manualImport) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.secondaryButton, .skip) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenBookmarksImportFailsAndSkipButtonIsClicked_passwordsImportFailureMessageIsShown() async throws { +// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: self.dataImporter) +// +// let m1 = try await initiateImport { _ in +// return [.bookmarks: .failure(MockImportError(action: .bookmarks, errorType: .decryptionError)), +// .passwords: .failure(MockImportError(action: .passwords, errorType: .keychainError))] +// } +// model = m1 +// +// model.performAction(.skip) +// +// XCTAssertEqual(model.importSource, .safari) +// XCTAssertEqual(model.summary, [ +// .init(.bookmarks, .failure(MockImportError(action: .bookmarks))), +// .init(.passwords, .failure(MockImportError(action: .passwords))), +// ]) +// XCTAssertEqual(model.screen, .error(dataType: .passwords, errorType: .keychainError)) +// XCTAssertEqual(model.actionButton, .manualImport) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.secondaryButton, .skip) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } +// +// func testWhenBookmarksImportFailsAndManualImportButtonIsClicked_passwordsImportFailureMessageIsShown() async throws { +// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: self.dataImporter) +// +// let m1 = try await initiateImport { _ in +// return [.bookmarks: .failure(MockImportError(action: .bookmarks, errorType: .decryptionError)), +// .passwords: .failure(MockImportError(action: .passwords, errorType: .keychainError))] +// } +// model = m1 +// +// model.performAction(.skip) +// +// XCTAssertEqual(model.importSource, .safari) +// XCTAssertEqual(model.summary, [ +// .init(.bookmarks, .failure(MockImportError(action: .bookmarks))), +// .init(.passwords, .failure(MockImportError(action: .passwords))), +// ]) +// XCTAssertEqual(model.screen, .error(dataType: .passwords, errorType: .keychainError)) +// XCTAssertEqual(model.actionButton, .manualImport) +// XCTAssertFalse(model.isActionButtonDisabled) +// XCTAssertEqual(model.secondaryButton, .skip) +// XCTAssertFalse(model.isSecondaryButtonDisabled) +// } + + // TODO: .bkm: .success, .passwd: .fail -> skip + // TODO: .bkm: .fail, .passwd: .success -> skip + // TODO: .bkm: .success, .passwd: .fail -> manual + // TODO: .bkm: .fail, .passwd: .success -> manual + + // TODO: test progress + func whenRequiresPrimaryPassword_passwordIsRequested() { + + } + + func testImport() { + + } + + func testFailureImportFileSucceeds() { + + } + + func testFailureImportFileFails() { + + } + + func test2xFailureImportFileSucceedsPasswordsFails() { + + } + + func test2xFailureImportFileSucceedsBookmarksFails() { + + } + + // TODO: and other combination: table of truth + + // TODO: when import source changes selected profile is reset + // TODO: when import source changes user report text is preserved + // TODO: when another error after back reported error is updated + + // MARK: - Helpers + + private var importTask: ((DataImportProgressCallback) async -> DataImportSummary)! + + private func dataImporter(for source: DataImport.Source, fileDataType: DataImport.DataType?, url: URL, primaryPassword: String?) -> DataImporter { + XCTAssertEqual(source, model.importSource) + if case .fileImport(let dataType) = model.screen { + XCTAssertEqual(dataType, fileDataType) + } else { + XCTAssertNil(fileDataType) + XCTAssertEqual(url, model.selectedProfile?.profileURL) + } + + return ImporterMock(password: primaryPassword, importTask: self.importTask) + } + + func expectButtons(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) where T : Equatable { + + } + +} + +private class ImporterMock: DataImporter { + + var password: String? + + var importableTypes: [DataImport.DataType] + + var keychainPasswordRequiredFor: Set + + init(password: String? = nil, importableTypes: [DataImport.DataType] = [.bookmarks, .passwords], keychainPasswordRequiredFor: Set = [], accessValidator: ((Set) -> [DataImport.DataType: any DataImportError]?)? = nil, importTask: ((DataImportProgressCallback) async -> DataImportSummary)? = nil) { + self.password = password + self.importableTypes = importableTypes + self.keychainPasswordRequiredFor = keychainPasswordRequiredFor + self.accessValidator = accessValidator + self.importTask = importTask + } + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + selectedDataTypes.intersects(keychainPasswordRequiredFor) + } + + var accessValidator: ((Set) -> [DataImport.DataType: any DataImportError]?)? + + func validateAccess(for types: Set) -> [DataImport.DataType : any DataImportError]? { + accessValidator?(types) + } + + var importTask: ((DataImportProgressCallback) async -> DataImportSummary)? + + func importData(types: Set) -> DataImportTask { + .detachedWithProgress { [importTask=importTask!]updateProgress in + await importTask(updateProgress) + } + } + +} + +private extension URL { + static let mockURL = URL(fileURLWithPath: "/Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/") + + static let testCSV = URL(fileURLWithPath: "/Users/Dax/Downloads/passwords.csv") + static let testHTML = URL(fileURLWithPath: "/Users/Dax/Downloads/bookmarks.html") + + static func profile(named name: String) -> URL { + return mockURL.appendingPathComponent(name) + } +} + +private extension DataImport.BrowserProfile { + static func test(for browser: ThirdPartyBrowser) -> Self { + .init(browser: browser, profileURL: .profile(named: "Test Profile")) + } + static func test2(for browser: ThirdPartyBrowser) -> Self { + .init(browser: browser, profileURL: .profile(named: "Test Profile 2")) + } + static func `default`(for browser: ThirdPartyBrowser) -> Self { + switch browser { + case .firefox, .tor: + .init(browser: browser, profileURL: .profile(named: DataImport.BrowserProfileList.Constants.firefoxDefaultProfileName)) + default: + .init(browser: browser, profileURL: .profile(named: DataImport.BrowserProfileList.Constants.chromiumDefaultProfileName)) + } + + } +} + +private struct MockImportError: DataImportError, CustomStringConvertible { + + enum OperationType: Int { + case failure + } + + var action: DataImportAction + var type: OperationType = .failure + + var underlyingError: Error? + + var errorType: DataImport.ErrorType = .other + + var description: String { + "Error(\(type.rawValue): \(errorType))" + } +} + +extension DataImportViewModel.ButtonType: CustomStringConvertible { + public var description: String { + switch self { + case .next(let screen): "next(\(screen))" + case .initiateImport(disabled: let disabled): "initiateImport\(disabled ? "(disabled)" : "")" + case .skip: "skip" + case .cancel: "cancel" + case .back: "back" + case .done: "done" + case .submit: "submit" + case .manualImport: "manualImport" + } + } +} + +extension DataImportViewModel.Screen: CustomStringConvertible { + public var description: String { + switch self { + case .profileAndDataTypesPicker: "profileAndDataTypesPicker" + case .moreInfo: "moreInfo" + case .getReadPermission(let url): "getReadPermission(\(url.path))" + case .error(dataType: let dataType, errorType: let errorType): "error(\(dataType): \(errorType))" + case .fileImport(let dataType): "fileImport(\(dataType))" + case .fileImportSummary(let dataType): "fileImportSummary(\(dataType))" + case .summary: "summary" + case .feedback: "feedback" + } + } +} + +private extension String { + + func with(_ addition: String) -> String { + guard !self.isEmpty else { return addition } + return self + " - " + addition + } + +} + +extension DataImportViewModel { + @MainActor mutating func performAction(_ buttonType: ButtonType) { + performAction(for: buttonType, dismiss: { assertionFailure("Unexpected dismiss") }) + } +} diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt new file mode 100644 index 0000000000..9c0d2ba4c2 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.brave + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.brave + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.brave + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.brave + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-brave + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.brave + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt new file mode 100644 index 0000000000..c402d2a428 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.safariTechnologyPreview + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.safariTechnologyPreview + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.safariTechnologyPreview + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.safariTechnologyPreview + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-safari-technology-preview + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.safariTechnologyPreview + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt new file mode 100644 index 0000000000..a7f5ea1373 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.tor + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.tor + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.tor + - chromiumPreferences: Optional.none + - fallbackProfileName: "default-release" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/default-release + ▿ BrowserProfile + - browser: ThirdPartyBrowser.tor + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-tor + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.tor + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt new file mode 100644 index 0000000000..c114086b18 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.vivaldi + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.vivaldi + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.vivaldi + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.vivaldi + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-vivaldi + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.vivaldi + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt new file mode 100644 index 0000000000..3fbc6eef82 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.yandex + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.yandex + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.yandex + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.yandex + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-yandex + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.yandex + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt new file mode 100644 index 0000000000..48134acd8d --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.chrome + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.chrome + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.chrome + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.chrome + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-chrome + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.chrome + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt new file mode 100644 index 0000000000..4c002e4981 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.chromium + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.chromium + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.chromium + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.chromium + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-chromium + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.chromium + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt new file mode 100644 index 0000000000..ead9a218a4 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.coccoc + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.coccoc + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.coccoc + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.coccoc + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-coccoc + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.coccoc + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt new file mode 100644 index 0000000000..e9d4a95f6c --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.edge + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.edge + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.edge + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.edge + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-edge + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.edge + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt new file mode 100644 index 0000000000..1f0e3a1739 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.firefox + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.firefox + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.firefox + - chromiumPreferences: Optional.none + - fallbackProfileName: "default-release" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/default-release + ▿ BrowserProfile + - browser: ThirdPartyBrowser.firefox + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-firefox + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.firefox + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt new file mode 100644 index 0000000000..80f8d3ce94 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.opera + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.opera + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.opera + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.opera + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-opera + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.opera + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt new file mode 100644 index 0000000000..26c5476ba2 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.operaGX + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.operaGX + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.operaGX + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.operaGX + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-operagx + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.operaGX + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt new file mode 100644 index 0000000000..c9695257b4 --- /dev/null +++ b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt @@ -0,0 +1,68 @@ +▿ Optional + ▿ some: DataImportViewModel + ▿ _successfulImportHappened: UserDefaultsWrapper> + - customUserDefaults: Optional.none + - defaultValue: Optional.none + - key: Key.homePageContinueSetUpImport + - setIfEmpty: false + ▿ browserProfiles: Optional + ▿ some: BrowserProfileList + - browser: ThirdPartyBrowser.safari + ▿ profiles: 3 elements + ▿ BrowserProfile + - browser: ThirdPartyBrowser.safari + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ BrowserProfile + - browser: ThirdPartyBrowser.safari + - chromiumPreferences: Optional.none + - fallbackProfileName: "Default" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default + ▿ BrowserProfile + - browser: ThirdPartyBrowser.safari + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile 2" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 + - validateProfileData: (Function) + - dataImporterFactory: (Function) + - importSource: source-safari + - importTask: Optional>, Never, DataImportProgressEvent>>.none + - loadProfiles: (Function) + - openPanelCallback: (Function) + - reportSenderFactory: (Function) + - requestPrimaryPasswordCallback: (Function) + ▿ screen: error(passwords: decryptionError) + ▿ error: (2 elements) + - dataType: passwords + - errorType: decryptionError + ▿ selectedDataTypes: 2 members + - bookmarks + - passwords + ▿ selectedProfile: Optional + ▿ some: BrowserProfile + - browser: ThirdPartyBrowser.safari + - chromiumPreferences: Optional.none + - fallbackProfileName: "Test Profile" + - fileStore: + - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile + ▿ summary: 2 elements + ▿ DataTypeImportResult + - dataType: bookmarks + ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) + ▿ success: DataTypeSummary + - duplicate: 0 + - failed: 0 + - successful: 10 + ▿ DataTypeImportResult + - dataType: passwords + ▿ result: .success(Error(0: decryptionError)) + ▿ failure: Error(0: decryptionError) + - action: logins + - type: OperationType.failure + - underlyingError: Optional.none + - errorType: decryptionError + - userReportText: "" From eb80e9a16cc7440b01182f0243a627225d5563b4 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 5 Dec 2023 01:05:23 +0600 Subject: [PATCH 40/83] show back button on getReadPermission, noData screens --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 041e3b279c..9637016ae7 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -619,7 +619,7 @@ extension DataImportViewModel { switch screen { case importSource.initialScreen, .feedback: return .cancel - case .moreInfo: + case .moreInfo, .getReadPermission, .noData: return .back default: return nil From 03e327f0cfea2b4f91b62a36d2ddd88d07f70c21 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 5 Dec 2023 01:06:30 +0600 Subject: [PATCH 41/83] spaces --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 1 - DuckDuckGo/DataImport/View/DataImportView.swift | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 9637016ae7..bd6d848797 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -248,7 +248,6 @@ struct DataImportViewModel { } } - if let nextScreen { self.screen = nextScreen } else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil, // no next data type manual import screen diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index d71a26f931..ebda7b2b81 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -520,7 +520,7 @@ extension DataImportViewModel.ButtonType { DataImportView(model: viewModel) // swiftlint:disable:next force_cast .environment(\EnvironmentValues.presentationMode as! WritableKeyPath, - Binding { + Binding { print("DISMISS!") }) From 9cbca920a21180f30c828877fd52ffe0d033901f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 7 Dec 2023 19:53:58 +0600 Subject: [PATCH 42/83] exclude .noData screen --- .../Model/DataImportViewModel.swift | 13 +- DuckDuckGo/Localizable.xcstrings | 359 +++++++++++++----- 2 files changed, 283 insertions(+), 89 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index bd6d848797..00419c4821 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -415,6 +415,15 @@ extension DataImport.Source { extension DataImport.DataType { + static func dataTypes(before dataType: DataImport.DataType, inclusive: Bool) -> [Self].SubSequence { + let index = Self.allCases.firstIndex(of: dataType)! + if inclusive { + return Self.allCases[...index] + } else { + return Self.allCases[.. [Self].SubSequence { let nextIndex = Self.allCases.firstIndex(of: dataType)! + 1 return Self.allCases[nextIndex...] @@ -595,7 +604,9 @@ extension DataImportViewModel { case .fileImport where screen == importSource.initialScreen: // no default action for File Import sources return nil - case .fileImport(dataType: let dataType, summary: _) where selectedDataTypes.subtracting([dataType]).isEmpty: + case .fileImport(dataType: let dataType, summary: _) + // exlude all skipped datatypes that are ordered before + where selectedDataTypes.subtracting(DataType.dataTypes(before: dataType, inclusive: true)).isEmpty: // no other data types to skip: return .cancel case .fileImport, .noData: diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index d551029c18..ed76f61b27 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -3,6 +3,33 @@ "strings" : { "" : { + }, + "\nTerms of Service" : { + + }, + "\nThe service cannot remove all of your information from the Internet." : { + + }, + "\nThe service is for limited and personal use only." : { + + }, + "\nWe don’t save your personal information for this service to function." : { + + }, + "\nWe may terminate access at any time." : { + + }, + "\nWe provide this beta service as-is, and without warranty." : { + + }, + "\nWe submit removal requests to data broker sites on your behalf." : { + + }, + "\nYou give DuckDuckGo authority to act on your Here's an updated version with the remaining content:" : { + + }, + "\nYour personal information is stored locally on your device." : { + }, "**%lld** tracking attempts blocked" : { @@ -23,6 +50,69 @@ }, "%lld tracking attempts blocked" : { + }, + "• Because data broker sites often have multi-step processes required to have information removed, and because they regularly update their databases with new personal information, this authorization includes ongoing action on your behalf solely to perform the service." : { + + }, + "• Our main " : { + + }, + "• The information you provide when you sign-up to use this service, for example your name, age, address, and phone number is stored on your device." : { + + }, + "• The only personal information we may receive is a confirmation email from data broker sites which is deleted within 72 hours." : { + + }, + "• The service is available for your personal use only. You represent and warrant that you will only initiate removal of your own personal information." : { + + }, + "• This beta product may collect more diagnostic data than our typical products. Examples of such data include: alerts of low memory, application restarts, and user engagement with product features." : { + + }, + "• This Privacy Policy is for our waitlist beta service." : { + + }, + "• This service is available on one device only." : { + + }, + "• This service is in beta, and your access to it is temporary." : { + + }, + "• This service is provided as-is and without warranties or guarantees of any kind." : { + + }, + "• This service requests removal from a limited number of data broker sites only. You understand that we cannot guarantee that the third-party sites will honor the requests, or that your personal information will not reappear in the future." : { + + }, + "• To the extent possible under applicable law, DuckDuckGo will not be liable for any damage or loss arising from your use of the service. In any event, the total aggregate liability of DuckDuckGo shall not exceed $25 or the equivalent in your local currency." : { + + }, + "• To use this service, you must be 18 or older." : { + + }, + "• We may find additional information on data broker sites through this scanning process, like alternative names or phone numbers, or the names of your relatives. This information is also stored locally on your device." : { + + }, + "• We may in the future transfer responsibility for the service to a subsidiary of DuckDuckGo. If that happens, you agree that references to “DuckDuckGo” will refer to our subsidiary, which will then become responsible for providing the service and for any liabilities relating to it." : { + + }, + "• We regularly re-scan data broker sites to check on the removal status of your information. If it has reappeared, we resubmit the removal request." : { + + }, + "• We reserve the right to terminate access at any time in our sole discretion, including for violation of these terms or our DuckDuckGo Terms of Service, which are incorporated by reference." : { + + }, + "• We submit removal requests to the data broker sites directly from your device, unlike other services where the removal process is initiated on remote servers." : { + + }, + "• We then scan data brokers from your device to check if any sites contain your personal information." : { + + }, + "• You hereby authorize DuckDuckGo to act on your behalf to request removal of your personal information from data broker sites." : { + + }, + "• You understand that we will only be able to request the removal of information based upon the information you provide to us." : { + }, "••••••••••••" : { @@ -218,6 +308,9 @@ } } } + }, + "also applies here." : { + }, "auth.alert.login.button" : { "comment" : "Authentication Alert Sign In Button", @@ -1694,7 +1787,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Personal Information Removal is free to use during the beta." + "value" : "Personal Information Removal is free during the beta.\nJoin the waitlist and we'll notify you when ready." } } } @@ -1855,78 +1948,6 @@ } } }, - "data-broker-protection.waitlist.invited.section-1.subtitle" : { - "comment" : "Subtitle for section 1 of the Personal Information Removal invited screen", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Automatically scans for your info, requests its removal, and re-scans regularly to ensure it doesn’t reappear." - } - } - } - }, - "data-broker-protection.waitlist.invited.section-1.title" : { - "comment" : "Title for section 1 of the Personal Information Removal invited screen", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Continuous Scan and Removal" - } - } - } - }, - "data-broker-protection.waitlist.invited.section-2.subtitle" : { - "comment" : "Subtitle for section 2 of the Personal Information Removal invited screen", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "The removal process is initiated on your device, and the info you provide during setup is stored on your device only." - } - } - } - }, - "data-broker-protection.waitlist.invited.section-2.title" : { - "comment" : "Title for section 2 of the Personal Information Removal invited screen", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Private by Design" - } - } - } - }, - "data-broker-protection.waitlist.invited.section-3.subtitle" : { - "comment" : "Subtitle for section 3 of the Personal Information Removal invited screen", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "See what information has been removed, and monitor progress of ongoing removals from your dashboard." - } - } - } - }, - "data-broker-protection.waitlist.invited.section-3.title" : { - "comment" : "Title for section 3 of the Personal Information Removal invited screen", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Real-Time Progress Updates" - } - } - } - }, "data-broker-protection.waitlist.invited.subtitle" : { "comment" : "Subtitle for Personal Information Removal invited screen", "extractionState" : "extracted_with_value", @@ -1963,18 +1984,6 @@ } } }, - "data-broker-protection.waitlist.join.subtitle.2" : { - "comment" : "Second subtitle for Personal Information Removal join waitlist screen", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Join the waitlist, and we’ll notify you when it’s your turn." - } - } - } - }, "data-broker-protection.waitlist.join.title" : { "comment" : "Title for Personal Information Removal join waitlist screen", "extractionState" : "extracted_with_value", @@ -4812,7 +4821,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Share Feedback..." + "value" : "Share Feedback…" } } } @@ -4829,6 +4838,18 @@ } } }, + "network.protection.status.menu.vpn.settings" : { + "comment" : "The status menu 'VPN Settings' menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN Settings…" + } + } + } + }, "network.protection.system.extension.please.reboot" : { "comment" : "Message shown to users when they try to enable NetP and they need to reboot the computer to complete the installation", "extractionState" : "extracted_with_value", @@ -7601,7 +7622,19 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Sync" + "value" : "Sync & Backup" + } + } + } + }, + "preferences.vpn" : { + "comment" : "Show VPN preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN" } } } @@ -7617,6 +7650,9 @@ } } } + }, + "Privacy Policy " : { + }, "quit" : { "comment" : "Quit button", @@ -8404,6 +8440,18 @@ "Undo" : { "comment" : "Main Menu Edit item" }, + "uninstall" : { + "comment" : "Uninstall button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall" + } + } + } + }, "unpin.tab" : { "comment" : "Menu item. Unpin as a verb", "extractionState" : "extracted_with_value", @@ -8442,6 +8490,138 @@ "View" : { "comment" : "Main Menu View" }, + "vpn.advanced.settings.title" : { + "comment" : "VPN Advanced section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Advanced" + } + } + } + }, + "vpn.button.title.uninstall.vpn" : { + "comment" : "Uninstall VPN button title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall DuckDuckGo VPN..." + } + } + } + }, + "vpn.general.title" : { + "comment" : "General section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General" + } + } + } + }, + "vpn.notifications.settings.title" : { + "comment" : "Notifications section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notifications" + } + } + } + }, + "vpn.setting.description.always.on" : { + "comment" : "Always ON setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically restores the VPN connection after interruption. For your security, this setting cannot be disabled." + } + } + } + }, + "vpn.setting.description.exclude.local.networks" : { + "comment" : "Exclude Local Networks setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bypass the VPN for local network connections, like to a printer." + } + } + } + }, + "vpn.setting.description.secure.dns" : { + "comment" : "Secure DNS setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Our VPN uses Secure DNS to keep your online activity private, so that your Internet provider can't see what websites you visit." + } + } + } + }, + "vpn.setting.title.connect.on.login" : { + "comment" : "Connect on Login setting title\n Display VPN status in the menu bar.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connect on login" + } + } + } + }, + "vpn.setting.title.exclude.local.networks" : { + "comment" : "Exclude Local Networks setting title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Exclude local networks" + } + } + } + }, + "vpn.uninstall.alert.informative.text" : { + "comment" : "Informative text for the alert that comes up when the user decides to uninstall our VPN", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstalling the DuckDuckGo VPN will disconnect the VPN and remove it from your device." + } + } + } + }, + "vpn.uninstall.alert.title" : { + "comment" : "Alert title when the user selects to uninstall our VPN", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to uninstall the VPN?" + } + } + } + }, "We couldn‘t find any %@…" : { "comment" : "Data import error message: Bookmarks or Passwords (%@) weren‘t found." }, @@ -8485,6 +8665,9 @@ }, "You could try importing %@ manually." : { "comment" : "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file." + }, + "You must be eligible to use this service." : { + }, "You'll be asked to enter your Primary Password for %@.\n\nImported passwords are encrypted and only stored on this computer." : { "comment" : "Warning that Firefox-based browser name (%@) data import would require entering a Primary Password for the browser." @@ -8509,4 +8692,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file From 33e0748e8f4db46b93937775ba86c635bcfe80e6 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 7 Dec 2023 20:13:58 +0600 Subject: [PATCH 43/83] drop .noData screen --- DuckDuckGo.xcodeproj/project.pbxproj | 9 +++ DuckDuckGo/Common/Localizables/UserText.swift | 2 +- DuckDuckGo/DataImport/DataImport.swift | 4 ++ .../Model/DataImportViewModel.swift | 61 ++++++++----------- .../DataImport/View/DataImportErrorView.swift | 44 +++++++++++++ .../View/DataImportNoDataView.swift | 8 +-- .../DataImport/View/DataImportView.swift | 41 +++++-------- DuckDuckGo/Localizable.xcstrings | 8 +-- 8 files changed, 105 insertions(+), 72 deletions(-) create mode 100644 DuckDuckGo/DataImport/View/DataImportErrorView.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 79f8342800..ed7fa28320 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2566,6 +2566,10 @@ B603FD9F2A02712E00F3FCA9 /* CIImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B603FD9D2A02712E00F3FCA9 /* CIImageExtension.swift */; }; B6040856274B830F00680351 /* DictionaryExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6040855274B830F00680351 /* DictionaryExtension.swift */; }; B604085C274B8FBA00680351 /* UnprotectedDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B604085A274B8CA300680351 /* UnprotectedDomains.xcdatamodeld */; }; + B6080BC52B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; + B6080BC62B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; + B6080BC72B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; + B6080BC82B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; B6085D062743905F00A9C456 /* CoreDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6085D052743905F00A9C456 /* CoreDataStore.swift */; }; B6085D092743AAB600A9C456 /* FireproofDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B6085D072743993C00A9C456 /* FireproofDomains.xcdatamodeld */; }; B60C6F7729B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F7629B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift */; }; @@ -3961,6 +3965,7 @@ B603FD9D2A02712E00F3FCA9 /* CIImageExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageExtension.swift; sourceTree = ""; }; B6040855274B830F00680351 /* DictionaryExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryExtension.swift; sourceTree = ""; }; B604085B274B8CA400680351 /* Permissions.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Permissions.xcdatamodel; sourceTree = ""; }; + B6080BC42B21E78100B418EF /* DataImportErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportErrorView.swift; sourceTree = ""; }; B6085D052743905F00A9C456 /* CoreDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStore.swift; sourceTree = ""; }; B6085D082743993D00A9C456 /* Permissions.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Permissions.xcdatamodel; sourceTree = ""; }; B60C6F7629B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchNonexistentDomainNavigationResponder.swift; sourceTree = ""; }; @@ -5354,6 +5359,7 @@ B62B48552ADE730D000DECE5 /* FileImportView.swift */, B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */, B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */, + B6080BC42B21E78100B418EF /* DataImportErrorView.swift */, B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */, 4B8AC93426B3B2FD00879451 /* NSAlert+DataImport.swift */, B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */, @@ -9620,6 +9626,7 @@ 3706FBFE293F65D500E42796 /* History.xcdatamodeld in Sources */, B68D21C92ACBC96E002DA3C2 /* MockPrivacyConfiguration.swift in Sources */, 3706FBFF293F65D500E42796 /* PermissionStore.swift in Sources */, + B6080BC62B21E78100B418EF /* DataImportErrorView.swift in Sources */, 3706FC00293F65D500E42796 /* PrivacyIconViewModel.swift in Sources */, 3706FC01293F65D500E42796 /* ChromiumBookmarksReader.swift in Sources */, 3706FC02293F65D500E42796 /* Downloads.xcdatamodeld in Sources */, @@ -10560,6 +10567,7 @@ 4B957A6F2AC7AE700062CA31 /* DuckPlayerTabExtension.swift in Sources */, 4B957A702AC7AE700062CA31 /* RecentlyClosedCoordinator.swift in Sources */, 4B957A712AC7AE700062CA31 /* URLRequestExtension.swift in Sources */, + B6080BC82B21E78100B418EF /* DataImportErrorView.swift in Sources */, 4B957A722AC7AE700062CA31 /* FaviconHostReference.swift in Sources */, 4B957A732AC7AE700062CA31 /* DownloadsTabExtension.swift in Sources */, 4B957A742AC7AE700062CA31 /* WebsiteBreakageSender.swift in Sources */, @@ -11306,6 +11314,7 @@ B6C0B23C26E87D900031CB7F /* NSAlert+ActiveDownloadsTermination.swift in Sources */, AAECA42024EEA4AC00EFA63A /* IndexPathExtension.swift in Sources */, 4B9579212AC687170062CA31 /* HardwareModel.swift in Sources */, + B6080BC52B21E78100B418EF /* DataImportErrorView.swift in Sources */, 4BE65478271FCD41008D1D63 /* PasswordManagementNoteItemView.swift in Sources */, AA5C8F632591021700748EB7 /* NSApplicationExtension.swift in Sources */, AA9E9A5625A3AE8400D1959D /* NSWindowExtension.swift in Sources */, diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 7dd9ef01a0..a66f73176e 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -559,7 +559,7 @@ struct UserText { static let importLoginsPasswords = NSLocalizedString("import.logins.passwords", value: "Passwords", comment: "Title text for the Passwords import option") static let initiateImport = NSLocalizedString("import.data.initiate", value: "Import", comment: "Button text for importing data") - static let skipImport = NSLocalizedString("import.data.skip", value: "Skip", comment: "Button text to skip a kind of imported data") + static let skipImportFormat = NSLocalizedString("import.data.skip.format", value: "Skip %@", comment: "Button text to skip a kind of imported data (Bookmarks or Passwords - %@)") static let done = NSLocalizedString("import.data.done", value: "Done", comment: "Button text for finishing the data import") static let manualImport = NSLocalizedString("import.data.manual", value: "Manual import…", comment: "Button text for initiating manual data import using a HTML or CSV file when automatic import has failed") diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 82348a0eb2..0e9199932a 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -152,6 +152,10 @@ enum DataImport { let duplicate: Int let failed: Int + var isEmpty: Bool { + successful == 0 && duplicate == 0 && failed == 0 + } + init(successful: Int, duplicate: Int, failed: Int) { self.successful = successful self.duplicate = duplicate diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 00419c4821..4a87621dda 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -27,6 +27,7 @@ struct DataImportViewModel { typealias BrowserProfileList = DataImport.BrowserProfileList typealias BrowserProfile = DataImport.BrowserProfile typealias DataType = DataImport.DataType + typealias DataTypeSummary = DataImport.DataTypeSummary @UserDefaultsWrapper(key: .homePageContinueSetUpImport, defaultValue: nil) var successfulImportHappened: Bool? @@ -66,7 +67,6 @@ struct DataImportViewModel { case getReadPermission(URL) case fileImport(dataType: DataType, summary: Set = []) case summary(Set) - case noData(DataType) case feedback var isFileImport: Bool { @@ -74,8 +74,7 @@ struct DataImportViewModel { } var fileImportDataType: DataType? { switch self { - case .fileImport(dataType: let dataType, summary: _), - .noData(let dataType): + case .fileImport(dataType: let dataType, summary: _): return dataType default: return nil @@ -96,8 +95,8 @@ struct DataImportViewModel { struct DataTypeImportResult: Equatable { let dataType: DataImport.DataType - let result: DataImportResult - init(_ dataType: DataImport.DataType, _ result: DataImportResult) { + let result: DataImportResult + init(_ dataType: DataImport.DataType, _ result: DataImportResult) { self.dataType = dataType self.result = result } @@ -199,7 +198,7 @@ struct DataImportViewModel { } guard dataTypes.compactMap({ testImportFailureReasons[$0] }).isEmpty else { - importTask = .detachedWithProgress { [testImportFailureReasons] progressUpdate in + importTask = .detachedWithProgress { [testImportFailureReasons] _ in var result = DataImportSummary() let selectedDataTypesWithoutFailureReasons = dataTypes.intersection(importer.importableTypes).subtracting(testImportFailureReasons.keys) var realSummary = DataImportSummary() @@ -237,12 +236,15 @@ struct DataImportViewModel { switch result { case .success(let summary): - if summary.successful == 0 && summary.duplicate == 0 && summary.failed == 0, nextScreen == nil { - nextScreen = .noData(dataType) + if summary.isEmpty, nextScreen == nil { + nextScreen = .fileImport(dataType: dataType) } case .failure(let error): + // show "no data to import" screen when no bookmarks|passwords found if case .noData = error.errorType, nextScreen == nil { - nextScreen = .noData(dataType) + nextScreen = .fileImport(dataType: dataType) + } else if nextScreen == nil { + nextScreen = .fileImport(dataType: dataType, summary: Set(summary.keys).subtracting([dataType])) } Pixel.fire(.dataImportFailed(source: importSource, error: error)) } @@ -318,11 +320,6 @@ struct DataImportViewModel { } } - /// Open Manual File Import screen action - mutating func manualImport(dataType: DataType) { - screen = .fileImport(dataType: dataType) - } - /// Select CSV/HTML file for import button press @MainActor mutating func selectFile() { guard let dataType = screen.fileImportDataType else { @@ -444,10 +441,15 @@ extension DataImportViewModel { selectedDataTypes.allSatisfy(isDataTypeSuccessfullyImported) } - private func isDataTypeSuccessfullyImported(_ dataType: DataType) -> Bool { - summary.reversed().contains(where: { dataTypeImportResult in - dataType == dataTypeImportResult.dataType && dataTypeImportResult.result.isSuccess - }) + func summary(for dataType: DataType) -> DataTypeSummary? { + if case .success(let summary) = self.summary.last(where: { $0.dataType == dataType })?.result { + return summary + } + return nil + } + + func isDataTypeSuccessfullyImported(_ dataType: DataType) -> Bool { + summary(for: dataType) != nil } private func screenForNextDataTypeRemainingToImport(after currentDataType: DataType? = nil) -> Screen? { @@ -456,9 +458,9 @@ extension DataImportViewModel { // if some of selected data types failed to import or not imported yet switch summary.last(where: { $0.dataType == dataType })?.result { case .success(let summary) where summary.successful == 0 && summary.duplicate == 0 && summary.failed == 0: - return .noData(dataType) + return .fileImport(dataType: dataType) case .failure(let error) where error.errorType == .noData: - return .noData(dataType) + return .fileImport(dataType: dataType) case .failure, .none: return .fileImport(dataType: dataType) case .success: @@ -468,7 +470,7 @@ extension DataImportViewModel { return nil } - private func error(for dataType: DataType) -> (any DataImportError)? { + func error(for dataType: DataType) -> (any DataImportError)? { if case .failure(let error) = summary.last(where: { $0.dataType == dataType })?.result { return error } @@ -493,19 +495,6 @@ extension DataImportViewModel { return DataImportViewSummarizedError(errors: errors) } - func hasDataTypeImportFailed(_ dataType: DataType) -> Bool { - var failureFound = false - for dataTypeImportResult in summary.reversed() where dataTypeImportResult.dataType == dataType { - switch dataTypeImportResult.result { - case .success: - return false - case .failure: - failureFound = true - } - } - return failureFound - } - private static func requestPrimaryPasswordCallback(_ source: DataImport.Source) -> String? { let alert = NSAlert.passwordRequiredAlert(source: source) let response = alert.runModal() @@ -609,7 +598,7 @@ extension DataImportViewModel { where selectedDataTypes.subtracting(DataType.dataTypes(before: dataType, inclusive: true)).isEmpty: // no other data types to skip: return .cancel - case .fileImport, .noData: + case .fileImport: return .skip case .summary(let dataTypes): @@ -629,7 +618,7 @@ extension DataImportViewModel { switch screen { case importSource.initialScreen, .feedback: return .cancel - case .moreInfo, .getReadPermission, .noData: + case .moreInfo, .getReadPermission: return .back default: return nil diff --git a/DuckDuckGo/DataImport/View/DataImportErrorView.swift b/DuckDuckGo/DataImport/View/DataImportErrorView.swift new file mode 100644 index 0000000000..72aafd457c --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportErrorView.swift @@ -0,0 +1,44 @@ +// +// DataImportErrorView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +struct DataImportErrorView: View { + + let source: DataImport.Source + let dataType: DataImport.DataType + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("We were unable to import \(dataType.displayName) directly from \(source.importSourceName).", + comment: "Message when data import fails from a browser. %1$@ - Bookmarks or Passwords; %2$@ - a browser name") + .font(.headline) + + Text("Let’s try doing it manually. It won’t take long.", + comment: "Suggestion to switch to a Manual File Data Import when data import fails.") + } + } + +} + +#Preview { + DataImportNoDataView(source: .chrome, dataType: .bookmarks) + .frame(width: 512 - 20) + .padding() +} diff --git a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift index 8cf6152de0..5e5f2c7324 100644 --- a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift +++ b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift @@ -23,23 +23,21 @@ struct DataImportNoDataView: View { let source: DataImport.Source let dataType: DataImport.DataType - let manualImportAction: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { Text("We couldn‘t find any \(dataType.displayName)…", comment: "Data import error message: Bookmarks or Passwords (%@) weren‘t found.") + .font(.headline) Text("You could try importing \(dataType.displayName) manually.", comment: "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file.") - - Button(UserText.manualImport, action: manualImportAction) } } } #Preview { - DataImportNoDataView(source: .chrome, dataType: .bookmarks) { print("Manual Import") } + DataImportNoDataView(source: .chrome, dataType: .bookmarks) .frame(width: 512 - 20) .padding() } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index ebda7b2b81..77ae943f54 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -125,25 +125,22 @@ struct DataImportView: View { model.initiateImport() } - case .noData(dataType: let dataType): - // no #dataType imported [skip], [manual import] - DataImportNoDataView(source: model.importSource, dataType: dataType) { - model.manualImport(dataType: dataType) - } - case .fileImport(let dataType, summary: let summaryTypes): if !summaryTypes.isEmpty { DataImportSummaryView(model, dataTypes: summaryTypes) + .padding(.bottom, 24) + + // if no data to import + } else if model.summary(for: dataType)?.isEmpty == true + || model.error(for: dataType)?.errorType == .noData { + + DataImportNoDataView(source: model.importSource, dataType: dataType) + .padding(.bottom, 24) // if browser importer failed - display error message - } else if model.hasDataTypeImportFailed(dataType) { - Text("We were unable to import \(dataType.displayName) directly from \(model.importSource.importSourceName).", - comment: "Message when data import fails from a browser. %1$@ - Bookmarks or Passwords; %2$@ - a browser name") - .font(.headline) - Spacer().frame(height: 8) - Text("Let’s try doing it manually. It won’t take long.", - comment: "Suggestion to switch to a Manual File Data Import when data import fails.") - Spacer().frame(height: 24) + } else if model.error(for: dataType) != nil { + DataImportErrorView(source: model.importSource, dataType: dataType) + .padding(.bottom, 24) } // manual file import instructions for CSV/HTML @@ -192,7 +189,7 @@ struct DataImportView: View { Button { model.performAction(for: button, dismiss: dismiss.callAsFunction) } label: { - Text(button.title) + Text(button.title(dataType: model.screen.fileImportDataType)) .frame(minWidth: 80 - 16 - 1) } .keyboardShortcut(button.shortcut) @@ -321,14 +318,14 @@ extension DataImportViewModel.ButtonType { extension DataImportViewModel.ButtonType { - var title: String { + func title(dataType: DataImport.DataType?) -> String { switch self { case .next: UserText.next case .initiateImport: UserText.initiateImport case .skip: - UserText.skipImport + String(format: UserText.skipImportFormat, dataType?.displayName ?? "") case .cancel: UserText.cancel case .back: @@ -345,10 +342,7 @@ extension DataImportViewModel.ButtonType { #Preview { { final class PreviewPreferences: ObservableObject { - @Published var shouldBookmarkImportFail = false - @Published var shouldPasswordsImportFail = false @Published var shouldDisplayProgress = false - static let shared = PreviewPreferences() } @@ -453,12 +447,7 @@ extension DataImportViewModel.ButtonType { var result = DataImportSummary() for type in types { - if (type == .bookmarks && PreviewPreferences.shared.shouldBookmarkImportFail) - || (type == .passwords && PreviewPreferences.shared.shouldPasswordsImportFail) { - result[type] = .failure(ImportError.err(MockError())) - } else { - result[type] = .success(.init(successful: Int.random(in: 0..<100000), duplicate: 0, failed: 0)) - } + result[type] = .success(.init(successful: Int.random(in: 0..<100000), duplicate: 0, failed: 0)) } return result diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index ed76f61b27..fb786d43a4 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -3710,14 +3710,14 @@ } } }, - "import.data.skip" : { - "comment" : "Button text to skip a kind of imported data", + "import.data.skip.format" : { + "comment" : "Button text to skip a kind of imported data (Bookmarks or Passwords - %@)", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Skip" + "value" : "Skip %@" } } } @@ -8692,4 +8692,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 684a986610b1fcef060d0628d6f5be60710e76e2 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 7 Dec 2023 21:36:38 +0600 Subject: [PATCH 44/83] show Skip for failed passwords if there were errors --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 4a87621dda..40bd6f285b 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -595,7 +595,9 @@ extension DataImportViewModel { return nil case .fileImport(dataType: let dataType, summary: _) // exlude all skipped datatypes that are ordered before - where selectedDataTypes.subtracting(DataType.dataTypes(before: dataType, inclusive: true)).isEmpty: + where selectedDataTypes.subtracting(DataType.dataTypes(before: dataType, inclusive: true)).isEmpty + // and no failures recorded - otherwise will skip to Feedback + && !summary.contains(where: { !$0.result.isSuccess }): // no other data types to skip: return .cancel case .fileImport: From 90603f9a01bbbab116dd1c97197ee8d7a0604ee1 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 7 Dec 2023 22:08:55 +0600 Subject: [PATCH 45/83] =?UTF-8?q?don=E2=80=98t=20get=20stuck=20on=20the=20?= =?UTF-8?q?same=20error=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 40bd6f285b..817f163eeb 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -241,10 +241,10 @@ struct DataImportViewModel { } case .failure(let error): // show "no data to import" screen when no bookmarks|passwords found - if case .noData = error.errorType, nextScreen == nil { + if case .noData = error.errorType, nextScreen == nil, screen != .fileImport(dataType: dataType) { nextScreen = .fileImport(dataType: dataType) - } else if nextScreen == nil { - nextScreen = .fileImport(dataType: dataType, summary: Set(summary.keys).subtracting([dataType])) + } else if nextScreen == nil, screen != .fileImport(dataType: dataType, summary: DataType.dataTypes(before: dataType)) { + nextScreen = .fileImport(dataType: dataType, summary: DataType.dataTypes(before: dataType)) } Pixel.fire(.dataImportFailed(source: importSource, error: error)) } From 944f0fd198f182c17e36cc3155be30778e28ebf0 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 7 Dec 2023 22:29:11 +0600 Subject: [PATCH 46/83] fix build --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 817f163eeb..dc6b9ddc64 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -243,8 +243,8 @@ struct DataImportViewModel { // show "no data to import" screen when no bookmarks|passwords found if case .noData = error.errorType, nextScreen == nil, screen != .fileImport(dataType: dataType) { nextScreen = .fileImport(dataType: dataType) - } else if nextScreen == nil, screen != .fileImport(dataType: dataType, summary: DataType.dataTypes(before: dataType)) { - nextScreen = .fileImport(dataType: dataType, summary: DataType.dataTypes(before: dataType)) + } else if nextScreen == nil, screen != .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType))) { + nextScreen = .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType))) } Pixel.fire(.dataImportFailed(source: importSource, error: error)) } From 5cd617f89c8850e216a252542b727a19d63a9c7b Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 8 Dec 2023 00:45:04 +0600 Subject: [PATCH 47/83] fix build --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index dc6b9ddc64..49ff9d069e 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -243,8 +243,8 @@ struct DataImportViewModel { // show "no data to import" screen when no bookmarks|passwords found if case .noData = error.errorType, nextScreen == nil, screen != .fileImport(dataType: dataType) { nextScreen = .fileImport(dataType: dataType) - } else if nextScreen == nil, screen != .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType))) { - nextScreen = .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType))) + } else if nextScreen == nil, screen != .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType, inclusive: false))) { + nextScreen = .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType, inclusive: false))) } Pixel.fire(.dataImportFailed(source: importSource, error: error)) } From 774f067a4be747145f3c209bc8c25a36e16e2d04 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 12 Dec 2023 21:06:50 +0600 Subject: [PATCH 48/83] fix typo --- DuckDuckGo/DataImport/DataImport.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 0e9199932a..9fbb6435f8 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -509,7 +509,7 @@ enum DataImportResult: CustomStringConvertible { case .success(let value): ".success(\(value))" case .failure(let error): - ".success(\(error))" + ".failure(\(error))" } } From 15eeb6ca476324b848dc8d6df7c0344ccd275056 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 12 Dec 2023 21:08:16 +0600 Subject: [PATCH 49/83] fix fiferox profile primary password --- DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift index 66a05a4e20..d7efae1b1e 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift @@ -124,7 +124,7 @@ internal class FirefoxDataImporter: DataImporter { func validateAccess(for selectedDataTypes: Set) -> [DataImport.DataType: any DataImportError]? { guard selectedDataTypes.contains(.passwords) else { return nil } - let loginReader = FirefoxLoginReader(firefoxProfileURL: profile.profileURL, primaryPassword: nil) + let loginReader = FirefoxLoginReader(firefoxProfileURL: profile.profileURL, primaryPassword: primaryPassword) do { _=try loginReader.getEncryptionKey() return nil From 4a6f62762359b7515ba4c1a34314a983b8233f5a Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 12 Dec 2023 21:10:44 +0600 Subject: [PATCH 50/83] fix ux flow on failure --- .../Model/DataImportViewModel.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 49ff9d069e..4761019cfb 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -72,6 +72,7 @@ struct DataImportViewModel { var isFileImport: Bool { if case .fileImport = self { true } else { false } } + var fileImportDataType: DataType? { switch self { case .fileImport(dataType: let dataType, summary: _): @@ -230,34 +231,39 @@ struct DataImportViewModel { if handleErrors(summary.compactMapValues { $0.error }) { return } var nextScreen: Screen? - // merge new import results into the model import summary + // merge new import results into the model import summary keeping the original DataType sorting order for (dataType, result) in DataType.allCases.compactMap({ dataType in summary[dataType].map { (dataType, $0) } }) { self.summary.append( .init(dataType, result) ) switch result { - case .success(let summary): - if summary.isEmpty, nextScreen == nil { - nextScreen = .fileImport(dataType: dataType) + case .success(let dataTypeSummary): + // if a data type can‘t be imported (Yandex/Passwords) - switch to its file import displaying successful import results + if dataTypeSummary.isEmpty, nextScreen == nil { + nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys)) } case .failure(let error): // show "no data to import" screen when no bookmarks|passwords found - if case .noData = error.errorType, nextScreen == nil, screen != .fileImport(dataType: dataType) { + if case .noData = error.errorType, screen != .fileImport(dataType: dataType), nextScreen == nil { nextScreen = .fileImport(dataType: dataType) - } else if nextScreen == nil, screen != .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType, inclusive: false))) { - nextScreen = .fileImport(dataType: dataType, summary: Set(DataType.dataTypes(before: dataType, inclusive: false))) + } else if !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil { + // switch to file import of the failed data type displaying successful import results + nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys)) } Pixel.fire(.dataImportFailed(source: importSource, error: error)) } } if let nextScreen { + log("mergeImportSummary: next screen: \(nextScreen)") self.screen = nextScreen } else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil, // no next data type manual import screen // and there should be failed data types (and non-recovered) selectedDataTypes.contains(where: { dataType in self.summary.last(where: { $0.dataType == dataType })?.result.error != nil }) { + log("mergeImportSummary: feedback") // after last failed datatype show feedback self.screen = .feedback } else { + log("mergeImportSummary: summary(\(Set(summary.keys)))") self.screen = .summary(Set(summary.keys)) } @@ -265,8 +271,6 @@ struct DataImportViewModel { successfulImportHappened = true NotificationCenter.default.post(name: .dataImportComplete, object: nil) } - - log("next screen: \(screen)") } /// handle recoverable errors (request primary password or file permission) From c1ddad4e413c2b19d27ba71871e9f45c0b402877 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 12 Dec 2023 21:11:58 +0600 Subject: [PATCH 51/83] design adjustments and fixes --- .../DataImport/View/DataImportErrorView.swift | 2 +- .../View/DataImportNoDataView.swift | 2 +- .../View/DataImportProfilePicker.swift | 29 +- .../View/DataImportSummaryView.swift | 35 +- .../View/DataImportTypePicker.swift | 2 +- .../DataImport/View/DataImportView.swift | 29 +- .../DataImport/View/FileImportView.swift | 317 ++++++++++++++---- .../DataImport/View/ReportFeedbackView.swift | 3 +- DuckDuckGo/Localizable.xcstrings | 149 ++++++-- 9 files changed, 434 insertions(+), 134 deletions(-) diff --git a/DuckDuckGo/DataImport/View/DataImportErrorView.swift b/DuckDuckGo/DataImport/View/DataImportErrorView.swift index 72aafd457c..b730d4483b 100644 --- a/DuckDuckGo/DataImport/View/DataImportErrorView.swift +++ b/DuckDuckGo/DataImport/View/DataImportErrorView.swift @@ -28,7 +28,7 @@ struct DataImportErrorView: View { VStack(alignment: .leading, spacing: 8) { Text("We were unable to import \(dataType.displayName) directly from \(source.importSourceName).", comment: "Message when data import fails from a browser. %1$@ - Bookmarks or Passwords; %2$@ - a browser name") - .font(.headline) + .bold() Text("Let’s try doing it manually. It won’t take long.", comment: "Suggestion to switch to a Manual File Data Import when data import fails.") diff --git a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift index 5e5f2c7324..e694842e22 100644 --- a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift +++ b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift @@ -27,7 +27,7 @@ struct DataImportNoDataView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text("We couldn‘t find any \(dataType.displayName)…", comment: "Data import error message: Bookmarks or Passwords (%@) weren‘t found.") - .font(.headline) + .bold() Text("You could try importing \(dataType.displayName) manually.", comment: "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file.") diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index 958f2e88f7..58da32140b 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -30,22 +30,20 @@ struct DataImportProfilePicker: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - if profiles.count > 1 { - Text("Select Profile:", comment: "Browser Profile picker title for Data Import") - .font(.headline) + Text("Select Profile:", comment: "Browser Profile picker title for Data Import") + .bold() - Picker(selection: Binding { - selectedProfile.flatMap(profiles.firstIndex(of:)) ?? 0 - } set: { - selectedProfile = profiles[safe: $0] - }) { - ForEach(profiles.indices, id: \.self) { idx in - Text(profiles[idx].profileName) - } - } label: {} - .pickerStyle(.menu) - .controlSize(.large) - } + Picker(selection: Binding { + selectedProfile.flatMap(profiles.firstIndex(of:)) ?? 0 + } set: { + selectedProfile = profiles[safe: $0] + }) { + ForEach(profiles.indices, id: \.self) { idx in + Text(profiles[idx].profileName) + } + } label: {} + .pickerStyle(.menu) + .controlSize(.large) } } @@ -67,4 +65,5 @@ struct DataImportProfilePicker: View { }) .padding() .frame(width: 512) + .font(.custom("SF Pro Text", size: 13)) } diff --git a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift index 580a22eeca..87f51a354c 100644 --- a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift +++ b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift @@ -47,28 +47,34 @@ struct DataImportSummaryView: View { case .fileImportComplete(.passwords): Text("Password import complete. You can now delete the saved passwords file.", comment: "message about Passwords Data Import completion") } - }().padding(.bottom, 16) + }().padding(.bottom, 4) ForEach(model.results, id: \.dataType) { item in switch (item.dataType, item.result) { case (.bookmarks, .success(let summary)): HStack { successImage() - Text("Bookmarks: \(summary.successful)", + Text("Bookmarks:", comment: "Data import summary format of how many bookmarks (%lld) were successfully imported.") + + Text(" " as String) + + Text(String(summary.successful)).bold() } if summary.duplicate > 0 { HStack { failureImage() - Text("Duplicate Bookmarks Skipped: \(summary.duplicate)", + Text("Duplicate Bookmarks Skipped:", comment: "Data import summary format of how many duplicate bookmarks (%lld) were skipped during import.") + + Text(" " as String) + + Text(String(summary.duplicate)).bold() } } if summary.failed > 0 { HStack { failureImage() - Text("Bookmark import failed: \(summary.failed)", + Text("Bookmark import failed:", comment: "Data import summary format of how many bookmarks (%lld) failed to import.") + + Text(" " as String) + + Text(String(summary.failed)).bold() } } @@ -89,14 +95,18 @@ struct DataImportSummaryView: View { case (.passwords, .success(let summary)): HStack { successImage() - Text("Passwords: \(summary.successful)", + Text("Passwords:", comment: "Data import summary format of how many passwords (%lld) were successfully imported.") + + Text(" " as String) + + Text(String(summary.successful)).bold() } if summary.failed > 0 { HStack { failureImage() - Text("Passwords import failed: \(summary.failed)", + Text("Passwords import failed: ", comment: "Data import summary format of how many passwords (%lld) failed to import.") + + Text(" " as String) + + Text(String(summary.failed)).bold() } } } @@ -118,11 +128,14 @@ private func failureImage() -> some View { #Preview { VStack { - DataImportSummaryView(model: .init(source: .chrome, summary: [ - .bookmarks: .success(.init(successful: 123, duplicate: 456, failed: 7890)), - .passwords: .success(.init(successful: 123, duplicate: 456, failed: 7890)) - ])) - .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + HStack { + DataImportSummaryView(model: .init(source: .chrome, summary: [ + .bookmarks: .success(.init(successful: 123, duplicate: 456, failed: 7890)), + .passwords: .success(.init(successful: 123, duplicate: 456, failed: 7890)) + ])) + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + Spacer() + } } .frame(width: 512) } diff --git a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift index b82b981915..8c872af0f9 100644 --- a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift @@ -30,7 +30,7 @@ struct DataImportTypePicker: View { VStack(alignment: .leading) { Text("Select data to import:", comment: "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks.") - .font(.headline) + .bold() ForEach(DataImport.DataType.allCases, id: \.self) { dataType in // display all types for a browser disabling unavailable options diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 77ae943f54..270701ef5f 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -65,6 +65,11 @@ struct DataImportView: View { .padding(.trailing, 20) .padding(.bottom, 32) + // if import in progress… + if let importProgress = model.importProgress { + progressView(importProgress) + } + Divider() viewFooter() @@ -78,6 +83,7 @@ struct DataImportView: View { } #endif } + .font(.custom("SF Pro Text", size: 13)) .frame(width: 512) .fixedSize() } @@ -85,7 +91,7 @@ struct DataImportView: View { private func viewHeader() -> some View { VStack(alignment: .leading, spacing: 0) { Text(UserText.importDataTitle) - .font(.headline) + .bold() .padding(.bottom, 16) // browser to import data from picker popup @@ -105,10 +111,12 @@ struct DataImportView: View { switch model.screen { case .profileAndDataTypesPicker: // Browser Profile picker - DataImportProfilePicker(profileList: model.browserProfiles, - selectedProfile: $model.selectedProfile) - .disabled(model.isImportSourcePickerDisabled) - .padding(.bottom, 24) + if model.browserProfiles?.validImportableProfiles.count ?? 0 > 1 { + DataImportProfilePicker(profileList: model.browserProfiles, + selectedProfile: $model.selectedProfile) + .disabled(model.isImportSourcePickerDisabled) + .padding(.bottom, 24) + } // Bookmarks/Passwords checkboxes DataImportTypePicker(viewModel: $model) @@ -129,9 +137,10 @@ struct DataImportView: View { if !summaryTypes.isEmpty { DataImportSummaryView(model, dataTypes: summaryTypes) .padding(.bottom, 24) + } // if no data to import - } else if model.summary(for: dataType)?.isEmpty == true + if model.summary(for: dataType)?.isEmpty == true || model.error(for: dataType)?.errorType == .noData { DataImportNoDataView(source: model.importSource, dataType: dataType) @@ -159,11 +168,6 @@ struct DataImportView: View { ReportFeedbackView(model: $model.reportModel) } - - // Import in progress… - if let importProgress = model.importProgress { - progressView(importProgress) - } } } @@ -459,7 +463,7 @@ extension DataImportViewModel.ButtonType { } } - let viewModel = DataImportViewModel(importSource: .chrome) { browser in + let viewModel = DataImportViewModel(importSource: .bookmarksHTML) { browser in guard case .chrome = browser else { print("empty profiles") return .init(browser: browser, profiles: []) @@ -514,6 +518,7 @@ extension DataImportViewModel.ButtonType { }) PreviewPreferencesView() + Spacer() } .frame(minHeight: 666) diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index 8e43de1afd..b3e893c7c3 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -38,7 +38,7 @@ struct FileImportView: View { } var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 16) { { switch dataType { case .bookmarks: @@ -46,10 +46,29 @@ struct FileImportView: View { case .passwords: Text("Import Passwords") } - }().font(.headline) + }().bold() - InstructionsView(fontName: "SF Pro Text", fontSize: 13) { + if [.onePassword7, .onePassword8].contains(source) { + HStack { + Image(.info) + Text(""" + You can find your version by selecting **\(source.importSourceName) → About 1Password** from the Menu Bar + """) + Spacer() + } + .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + .background(Color("BlackWhite5")) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color("SeparatorColor"), + style: StrokeStyle(lineWidth: 1)) + ) + .padding(.top, 8) + .padding(.bottom, 8) + } + InstructionsView { switch (source, dataType) { case (.chrome, .passwords): NSLocalizedString("import.csv.instructions.chrome", value: """ @@ -60,21 +79,39 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from Google Chrome browser. - %d is a step number; %s is a Browser name; %@ is for a button image to click + %N$d - step number + %2$s - browser name (Chrome) + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button **bold text**; _italic text_ """) source.importSourceName NSImage.menuVertical16 button("Select Passwords CSV File…") - case (.brave, .passwords), - (.chromium, .passwords), - (.coccoc, .passwords), - (.edge, .passwords), - (.vivaldi, .passwords), - (.opera, .passwords), - (.operaGX, .passwords): + case (.brave, .passwords): + NSLocalizedString("import.csv.instructions.brave", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Password Manager** + %d Click %@ **at the top left** of the Password Manager and select **Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Brave browser. + %N$d - step number + %2$s - browser name (Brave) + %4$@, %6$@ - hamburger menu icon + %10$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuHamburger16 + button("Select Passwords CSV File…") + case (.chromium, .passwords), + (.edge, .passwords): NSLocalizedString("import.csv.instructions.chromium", value: """ %d Open **%s** %d In a fresh tab, click %@ then **Password Manager → Settings** @@ -83,7 +120,85 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from Chromium-based browsers. - %d is a step number; %s is a Browser name; %@ is for a button image to click + %N$d - step number + %2$s - browser name + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button("Select Passwords CSV File…") + + case (.coccoc, .passwords): + NSLocalizedString("import.csv.instructions.coccoc", value: """ + %d Open **%s** + %d Type “_coccoc://settings/passwords_” into the Address field + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name (Cốc Cốc) + %5$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button("Select Passwords CSV File…") + + case (.opera, .passwords): + NSLocalizedString("import.csv.instructions.opera", value: """ + %d Open **%s** + %d Use the Menu Bar to select **View → Show Password Manager** + %d Select **Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name (Opera) + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button("Select Passwords CSV File…") + + case (.vivaldi, .passwords): + NSLocalizedString("import.csv.instructions.vivaldi", value: """ + %d Open **%s** + %d Type “_chrome://settings/passwords_” into the Address field + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords exported as CSV from Vivaldi browser. + %N$d - step number + %2$s - browser name (Vivaldi) + %5$@ - menu button icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button("Select Passwords CSV File…") + + case (.operaGX, .passwords): + NSLocalizedString("import.csv.instructions.operagx", value: """ + %d Open **%s** + %d Use the Menu Bar to select **View → Show Password Manager** + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name (Opera GX) + %5$@ - menu button icon + %8$@ - “Select Passwords CSV File” button **bold text**; _italic text_ """) source.importSourceName @@ -92,7 +207,7 @@ struct FileImportView: View { case (.yandex, .passwords): NSLocalizedString("import.csv.instructions.yandex", value: """ - %d Open **Yandex** + %d Open **%s** %d Click %@ to open the application menu then click **Passwords and cards** %d Click %@ then **Export passwords** %d Choose **To a text file (not secure)** and click **Export** @@ -100,7 +215,10 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from Yandex Browser. - %d is a step number; %@ is for a button image to click + %N$d - step number + %2$s - browser name (Yandex) + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button **bold text**; _italic text_ """) NSImage.menuHamburger16 @@ -108,13 +226,10 @@ struct FileImportView: View { button("Select Passwords CSV File…") case (.brave, .bookmarks), - (.chrome, .bookmarks), - (.chromium, .bookmarks), - (.coccoc, .bookmarks), - (.edge, .bookmarks), - (.vivaldi, .bookmarks), - (.opera, .bookmarks), - (.operaGX, .bookmarks): + (.chrome, .bookmarks), + (.chromium, .bookmarks), + (.coccoc, .bookmarks), + (.edge, .bookmarks): NSLocalizedString("import.html.instructions.chromium", value: """ %d Open **%s** %d Use the Menu Bar to select **Bookmarks → Bookmark Manager** @@ -123,13 +238,67 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Bookmarks exported as HTML from Chromium-based browsers. - %d is a step number; %s is a Browser name; %@ is for a button image to click + %N$d - step number + %2$s - browser name + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button **bold text**; _italic text_ """) source.importSourceName NSImage.menuVertical16 button("Select Bookmarks HTML File…") + case (.vivaldi, .bookmarks): + NSLocalizedString("import.html.instructions.vivaldi", value: """ + %d Open **%s** + %d Use the Menu Bar to select **File → Export Bookmarks…** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Vivaldi browser. + %N$d - step number + %2$s - browser name (Vivaldi) + %6$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button("Select Bookmarks HTML File…") + + case (.opera, .bookmarks): + NSLocalizedString("import.html.instructions.opera", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmarks** + %d Click **Open full Bookmarks view…** in the bottom left + %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Opera browser. + %N$d - step number + %2$s - browser name (Opera) + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button("Select Bookmarks HTML File…") + + case (.operaGX, .bookmarks): + NSLocalizedString("import.html.instructions.operagx", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmarks** + %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Opera GX browser. + %N$d - step number + %2$s - browser name (Opera GX) + %7$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button("Select Bookmarks HTML File…") + case (.yandex, .bookmarks): NSLocalizedString("import.html.instructions.yandex", value: """ %d Open **%s** @@ -139,7 +308,10 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Bookmarks exported as HTML from Yandex Browser. - %d is a step number; %s is a Browser name; %@ is for a button image to click + %N$d - step number + %2$s - browser name (Yandex) + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button **bold text**; _italic text_ """) source.importSourceName @@ -154,7 +326,8 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from Safari. - %d is a step number; %@ is for a button image to click + %N$d - step number + %5$@ - “Select Passwords CSV File” button **bold text**; _italic text_ """) @@ -168,7 +341,8 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Bookmarks exported as HTML from Safari. - %d is a step number; %@ is for a button image to click + %N$d - step number + %5$@ - “Select Bookmarks HTML File” button **bold text**; _italic text_ """) button("Select Bookmarks HTML File…") @@ -182,12 +356,15 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from Firefox. - %d is a step number; %s is a Browser name; %@ is for a button image to click + %N$d - step number + %2$s - browser name (Firefox) + %4$@, %6$@ - hamburger menu icon + %9$@ - “Select Passwords CSV File” button **bold text**; _italic text_ """) source.importSourceName NSImage.menuHamburger16 - NSImage.menuVertical16 + NSImage.menuHorizontal16 button("Select Passwords CSV File…") case (.firefox, .bookmarks), (.tor, .bookmarks): @@ -199,7 +376,10 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Bookmarks exported as HTML from Firefox based browsers. - %d is a step number; %s is a Browser name; %@ is for a button image to click + %N$d - step number + %2$s - browser name (Firefox) + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button **bold text**; _italic text_ """) source.importSourceName @@ -216,7 +396,8 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from 1Password 8. - %d is a step number; %s is 1Password app name; %@ is for a button image to click + %2$s - app name (1Password) + %8$@ - “Select 1Password CSV File” button **bold text**; _italic text_ """) source.importSourceName @@ -233,7 +414,8 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from 1Password 7. - %d is a step number; %s is 1Password app name; %@ is for a button image to click + %2$s - app name (1Password) + %9$@ - “Select 1Password CSV File” button **bold text**; _italic text_ """) source.importSourceName @@ -249,7 +431,9 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from Bitwarden. - %d is a step number; %s is Bitwarden app name; %@ is for a button image to click + %2$s - app name (Bitwarden) + %7$@ - hamburger menu icon + %9$@ - “Select Bitwarden CSV File” button **bold text**; _italic text_ """) source.importSourceName @@ -266,7 +450,8 @@ struct FileImportView: View { %d %@ """, comment: """ Instructions to import Passwords as CSV from LastPass. - %d is a step number; %s is LastPass app name; %@ is for a button image to click + %2$s - app name (LastPass) + %8$@ - “Select LastPass CSV File” button **bold text**; _italic text_ """) source.importSourceName @@ -281,7 +466,8 @@ struct FileImportView: View { %@ """, comment: """ Instructions to import a generic CSV passwords file. - %d is a step number; %@ is for a button image to click + %N$d - step number + %3$@ - “Select Passwords CSV File” button **bold text**; _italic text_ """) @@ -290,17 +476,16 @@ struct FileImportView: View { case (.bookmarksHTML, .bookmarks): NSLocalizedString("import.html.instructions.generic", value: """ %d Open your old browser - %d Click %@ then select **Bookmarks → Bookmark Manager** - %d Click %@ then **Export bookmarks to HTML…** + %d Open **Bookmark Manager** + %d Export bookmarks to HTML… %d Save the file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import a generic HTML Bookmarks file. - %d is a step number; %@ is for a button image to click + %N$d - step number + %6$@ - “Select Bookmarks HTML File” button **bold text**; _italic text_ """) - NSImage.menuHamburger16 - NSImage.menuVertical16 button("Select Bookmarks HTML File…") case (.bookmarksHTML, .passwords), @@ -386,18 +571,11 @@ struct InstructionsView: View { case view(AnyView) } - // Text font - used to calculate inline image baseline offset and set Text/Font modifier - let fontName: String - let fontSize: CGFloat - // View Model private let instructions: [[InstructionsViewItem]] // swiftlint:disable:next function_body_length cyclomatic_complexity - init(fontName: String, fontSize: CGFloat, @InstructionsBuilder builder: () -> [InstructionsItem]) { - self.fontName = fontName - self.fontSize = fontSize - + init(@InstructionsBuilder builder: () -> [InstructionsItem]) { var args = builder() guard case .string(let format) = args.first else { @@ -548,16 +726,19 @@ struct InstructionsView: View { } var body: some View { - ForEach(instructions.indices, id: \.self) { i in - HStack(spacing: 4) { - ForEach(instructions[i].indices, id: \.self) { j in - switch instructions[i][j] { - case .lineNumber(let number): - CircleNumberView(number: number) - case .textItems(let textParts): - Text(textParts, fontName: fontName, fontSize: fontSize) - case .view(let view): - view + VStack(alignment: .leading, spacing: 8) { + ForEach(instructions.indices, id: \.self) { i in + HStack(spacing: 8) { + ForEach(instructions[i].indices, id: \.self) { j in + switch instructions[i][j] { + case .lineNumber(let number): + CircleNumberView(number: number) + case .textItems(let textParts): + Text(textParts) + .makeSelectable() + case .view(let view): + view + } } } } @@ -568,15 +749,15 @@ struct InstructionsView: View { private extension Text { - init(_ textPart: InstructionsView.TextItem, fontName: String, fontSize: CGFloat) { + init(_ textPart: InstructionsView.TextItem) { switch textPart { case .image(let image): self.init(Image(nsImage: image)) - self = self.baselineOffset(fontSize - image.size.height) + self = self + .baselineOffset(-3) case .text(let text, let isBold, let isItalic): self.init(text) - self = self.font(.custom(fontName, size: fontSize)) if isBold { self = self.bold() } @@ -586,18 +767,18 @@ private extension Text { } } - init(_ textParts: [InstructionsView.TextItem], fontName: String, fontSize: CGFloat) { + init(_ textParts: [InstructionsView.TextItem]) { guard !textParts.isEmpty else { assertionFailure("Empty TextParts") self.init("") return } - self.init(textParts[0], fontName: fontName, fontSize: fontSize) + self.init(textParts[0]) guard textParts.count > 1 else { return } for textPart in textParts[1...] { // swiftlint:disable:next shorthand_operator - self = self + Text(textPart, fontName: fontName, fontSize: fontSize) + self = self + Text(textPart) } } @@ -614,7 +795,7 @@ struct CircleNumberView: View { .overlay( Text("\(number)") .foregroundColor(.onboardingActionButton) - .font(.headline) + .bold() ) } @@ -624,7 +805,11 @@ struct CircleNumberView: View { // MARK: - Preview #Preview { - FileImportView(source: .chrome, dataType: .passwords, isButtonDisabled: false) - .frame(width: 512 - 20) - + HStack { + FileImportView(source: .onePassword7, dataType: .passwords, isButtonDisabled: false) + .padding() + .frame(width: 512 - 20) + } + .font(.custom("SF Pro Text", size: 13)) + .background(Color.white) } diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift index 4e98d04372..491a8e77aa 100644 --- a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -34,7 +34,7 @@ struct ReportFeedbackView: View { comment: "Data import failure Report dialog title containing a message that not only automatic data import has failed failed but manual browser data import didn‘t work either.") } }() - .font(.headline) + .bold() .padding(.bottom, 8) VStack(alignment: .leading, spacing: 12) { @@ -69,7 +69,6 @@ struct ReportFeedbackView: View { HStack { Text("Add any details that you think may help us fix the problem", comment: "Data import failure Report dialog suggestion to provide a comments with extra details helping to identify the data import problem.") - .font(.custom("SF Pro Text", size: 13)) .foregroundColor(Color(.placeholderTextColor)) Spacer() }.padding(EdgeInsets(top: 11, leading: 11, bottom: 0, trailing: 11)) diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index fb786d43a4..03bdb96bb7 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -1148,7 +1148,7 @@ } } }, - "Bookmark import failed: %lld" : { + "Bookmark import failed:" : { "comment" : "Data import summary format of how many bookmarks (%lld) failed to import." }, "Bookmark import failed." : { @@ -1205,7 +1205,7 @@ "Bookmarks Import Complete:" : { "comment" : "Bookmarks Data Import result summary headline" }, - "Bookmarks: %lld" : { + "Bookmarks:" : { "comment" : "Data import summary format of how many bookmarks (%lld) were successfully imported." }, "Bookmarks…" : { @@ -2440,7 +2440,7 @@ } } }, - "Duplicate Bookmarks Skipped: %lld" : { + "Duplicate Bookmarks Skipped:" : { "comment" : "Data import summary format of how many duplicate bookmarks (%lld) were skipped during import." }, "duplicate.tab" : { @@ -3429,7 +3429,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "HTML Bookmarks File" + "value" : "HTML Bookmarks File (for other browsers)" } } } @@ -3507,7 +3507,7 @@ } }, "import.csv.instructions.bitwarden" : { - "comment" : "Instructions to import Passwords as CSV from Bitwarden.\n%d is a step number; %s is Bitwarden app name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Bitwarden.\n%2$s - app name (Bitwarden)\n%7$@ - hamburger menu icon\n%9$@ - “Select Bitwarden CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3518,8 +3518,20 @@ } } }, + "import.csv.instructions.brave" : { + "comment" : "Instructions to import Passwords as CSV from Brave browser.\n%N$d - step number\n%2$s - browser name (Brave)\n%4$@, %6$@ - hamburger menu icon\n%10$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Password Manager**\n%5$d Click %6$@ **at the top left** of the Password Manager and select **Settings**\n%7$d Find “Export Passwords” and click **Download File**\n%8$d Save the passwords file someplace you can find it (e.g. Desktop)\n%9$d %10$@" + } + } + } + }, "import.csv.instructions.chrome" : { - "comment" : "Instructions to import Passwords as CSV from Google Chrome browser.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Google Chrome browser.\n%N$d - step number\n%2$s - browser name (Chrome)\n%4$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3531,7 +3543,7 @@ } }, "import.csv.instructions.chromium" : { - "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name\n%4$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3542,8 +3554,20 @@ } } }, + "import.csv.instructions.coccoc" : { + "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name (Cốc Cốc)\n%5$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Type “_coccoc://settings/passwords_” into the Address field\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + } + } + } + }, "import.csv.instructions.firefox" : { - "comment" : "Instructions to import Passwords as CSV from Firefox.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Firefox.\n%N$d - step number\n%2$s - browser name (Firefox)\n%4$@, %6$@ - hamburger menu icon\n%9$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3555,7 +3579,7 @@ } }, "import.csv.instructions.generic" : { - "comment" : "Instructions to import a generic CSV passwords file.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import a generic CSV passwords file.\n%N$d - step number\n%3$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3567,7 +3591,7 @@ } }, "import.csv.instructions.lastpass" : { - "comment" : "Instructions to import Passwords as CSV from LastPass.\n%d is a step number; %s is LastPass app name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from LastPass.\n%2$s - app name (LastPass)\n%8$@ - “Select LastPass CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3579,7 +3603,7 @@ } }, "import.csv.instructions.onePassword7" : { - "comment" : "Instructions to import Passwords as CSV from 1Password 7.\n%d is a step number; %s is 1Password app name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from 1Password 7.\n%2$s - app name (1Password)\n%9$@ - “Select 1Password CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3591,7 +3615,7 @@ } }, "import.csv.instructions.onePassword8" : { - "comment" : "Instructions to import Passwords as CSV from 1Password 8.\n%d is a step number; %s is 1Password app name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from 1Password 8.\n%2$s - app name (1Password)\n%8$@ - “Select 1Password CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3602,8 +3626,32 @@ } } }, + "import.csv.instructions.opera" : { + "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name (Opera)\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Select **Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.operagx" : { + "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name (Opera GX)\n%5$@ - menu button icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + } + } + } + }, "import.csv.instructions.safari" : { - "comment" : "Instructions to import Passwords as CSV from Safari.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Safari.\n%N$d - step number\n%5$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3614,14 +3662,26 @@ } } }, + "import.csv.instructions.vivaldi" : { + "comment" : "Instructions to import Passwords exported as CSV from Vivaldi browser.\n%N$d - step number\n%2$s - browser name (Vivaldi)\n%5$@ - menu button icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Type “_chrome://settings/passwords_” into the Address field\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, "import.csv.instructions.yandex" : { - "comment" : "Instructions to import Passwords as CSV from Yandex Browser.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Yandex Browser.\n%N$d - step number\n%2$s - browser name (Yandex)\n%4$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **Yandex**\n%2$d Click %3$@ to open the application menu then click **Passwords and cards**\n%4$d Click %5$@ then **Export passwords**\n%6$d Choose **To a text file (not secure)** and click **Export**\n%7$d Save the passwords file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords and cards**\n%5$d Click %6$@ then **Export passwords**\n%7$d Choose **To a text file (not secure)** and click **Export**\n%8$d Save the passwords file someplace you can find it (e.g. Desktop)\n%9$d %10$@" } } } @@ -3723,7 +3783,7 @@ } }, "import.html.instructions.chromium" : { - "comment" : "Instructions to import Bookmarks exported as HTML from Chromium-based browsers.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Bookmarks exported as HTML from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name\n%5$@ - hamburger menu icon\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3735,7 +3795,7 @@ } }, "import.html.instructions.firefox" : { - "comment" : "Instructions to import Bookmarks exported as HTML from Firefox based browsers.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Bookmarks exported as HTML from Firefox based browsers.\n%N$d - step number\n%2$s - browser name (Firefox)\n%5$@ - hamburger menu icon\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3747,19 +3807,43 @@ } }, "import.html.instructions.generic" : { - "comment" : "Instructions to import a generic HTML Bookmarks file.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import a generic HTML Bookmarks file.\n%N$d - step number\n%6$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open your old browser\n%2$d Click %3$@ then select **Bookmarks → Bookmark Manager**\n%4$d Click %5$@ then **Export bookmarks to HTML…**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + "value" : "%1$d Open your old browser\n%2$d Open **Bookmark Manager**\n%3$d Export bookmarks to HTML…\n%4$d Save the file someplace you can find it (e.g., Desktop)\n%5$d %6$@" + } + } + } + }, + "import.html.instructions.opera" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Opera browser.\n%N$d - step number\n%2$s - browser name (Opera)\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Bookmarks**\n%4$d Click **Open full Bookmarks view…** in the bottom left\n%5$d Click **Import/Export…** in the bottom left and select **Export Bookmarks**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.html.instructions.operagx" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Opera GX browser.\n%N$d - step number\n%2$s - browser name (Opera GX)\n%7$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Bookmarks**\n%4$d Click **Import/Export…** in the bottom left and select **Export Bookmarks**\n%5$d Save the file someplace you can find it (e.g., Desktop)\n%6$d %7$@" } } } }, "import.html.instructions.safari" : { - "comment" : "Instructions to import Bookmarks exported as HTML from Safari.\n%d is a step number; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Bookmarks exported as HTML from Safari.\n%N$d - step number\n%5$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3770,8 +3854,20 @@ } } }, + "import.html.instructions.vivaldi" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Vivaldi browser.\n%N$d - step number\n%2$s - browser name (Vivaldi)\n%6$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **File → Export Bookmarks…**\n%4$d Save the file someplace you can find it (e.g., Desktop)\n%5$d %6$@" + } + } + } + }, "import.html.instructions.yandex" : { - "comment" : "Instructions to import Bookmarks exported as HTML from Yandex Browser.\n%d is a step number; %s is a Browser name; %@ is for a button image to click\n**bold text**; _italic text_", + "comment" : "Instructions to import Bookmarks exported as HTML from Yandex Browser.\n%N$d - step number\n%2$s - browser name (Yandex)\n%5$@ - hamburger menu icon\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3789,7 +3885,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "CSV Passwords File" + "value" : "CSV Passwords File (for other browsers)" } } } @@ -5806,13 +5902,13 @@ } } }, - "Passwords import failed: %lld" : { + "Passwords import failed: " : { "comment" : "Data import summary format of how many passwords (%lld) failed to import." }, "Passwords import failed." : { "comment" : "Data import summary message of failed passwords import." }, - "Passwords: %lld" : { + "Passwords:" : { "comment" : "Data import summary format of how many passwords (%lld) were successfully imported." }, "Passwords…" : { @@ -8662,6 +8758,9 @@ }, "Window" : { "comment" : "Main Menu " + }, + "You can find your version by selecting **%@ → About 1Password** from the Menu Bar" : { + }, "You could try importing %@ manually." : { "comment" : "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file." @@ -8692,4 +8791,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file From f75221039683c329e0c3b19b79677ca98c927fb9 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 12 Dec 2023 21:46:27 +0600 Subject: [PATCH 52/83] fix yandex csv import instructions --- DuckDuckGo/DataImport/View/FileImportView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index b3e893c7c3..f78baa7dc9 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -221,6 +221,7 @@ struct FileImportView: View { %8$@ - “Select Passwords CSV File” button **bold text**; _italic text_ """) + source.importSourceName NSImage.menuHamburger16 NSImage.menuVertical16 button("Select Passwords CSV File…") @@ -806,7 +807,7 @@ struct CircleNumberView: View { #Preview { HStack { - FileImportView(source: .onePassword7, dataType: .passwords, isButtonDisabled: false) + FileImportView(source: .yandex, dataType: .passwords, isButtonDisabled: false) .padding() .frame(width: 512 - 20) } From 17165e5f9e4a676ce1771ef25a7380ac8f946d7a Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 13 Dec 2023 18:15:36 +0600 Subject: [PATCH 53/83] FileImportView testability --- DuckDuckGo.xcodeproj/project.pbxproj | 13 +- DuckDuckGo/Common/Localizables/UserText.swift | 5 + .../View/SwiftUI/SheetHostingWindow.swift | 2 +- DuckDuckGo/DataImport/DataImport.swift | 4 +- .../Model/DataImportViewModel.swift | 39 + .../DataImport/View/FileImportView.swift | 876 +++++++++--------- DuckDuckGo/Localizable.xcstrings | 49 +- .../DataImport/DataImportViewModelTests.swift | 170 ++-- .../FileImportViewLocalizationTests.swift | 137 +++ ...rdsImportFails_manualImportSuggested.1.txt | 68 -- ...dsImportFails_manualImportSuggested.10.txt | 68 -- ...dsImportFails_manualImportSuggested.11.txt | 68 -- ...dsImportFails_manualImportSuggested.12.txt | 68 -- ...dsImportFails_manualImportSuggested.13.txt | 68 -- ...rdsImportFails_manualImportSuggested.2.txt | 68 -- ...rdsImportFails_manualImportSuggested.3.txt | 68 -- ...rdsImportFails_manualImportSuggested.4.txt | 68 -- ...rdsImportFails_manualImportSuggested.5.txt | 68 -- ...rdsImportFails_manualImportSuggested.6.txt | 68 -- ...rdsImportFails_manualImportSuggested.7.txt | 68 -- ...rdsImportFails_manualImportSuggested.8.txt | 68 -- ...rdsImportFails_manualImportSuggested.9.txt | 68 -- 22 files changed, 766 insertions(+), 1413 deletions(-) create mode 100644 UnitTests/DataImport/FileImportViewLocalizationTests.swift delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt delete mode 100644 UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ed7fa28320..ce60c54a69 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2568,7 +2568,6 @@ B604085C274B8FBA00680351 /* UnprotectedDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B604085A274B8CA300680351 /* UnprotectedDomains.xcdatamodeld */; }; B6080BC52B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; B6080BC62B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; - B6080BC72B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; B6080BC82B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; B6085D062743905F00A9C456 /* CoreDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6085D052743905F00A9C456 /* CoreDataStore.swift */; }; B6085D092743AAB600A9C456 /* FireproofDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B6085D072743993C00A9C456 /* FireproofDomains.xcdatamodeld */; }; @@ -2713,6 +2712,8 @@ B662D3D92755D7AD0035D4D6 /* PixelStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3D82755D7AD0035D4D6 /* PixelStoreTests.swift */; }; B662D3DE275613BB0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */; }; B662D3DF275616FF0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */; }; + B6656E0D2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */; }; + B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */; }; B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; B6676BE22AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; B6685E3D29A602D90043D2EE /* ExternalAppSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B687B7CB2947A1E9001DEA6F /* ExternalAppSchemeHandler.swift */; }; @@ -2802,8 +2803,6 @@ B69B50542726CD8100758A2B /* atb-with-update.json in Resources */ = {isa = PBXBuildFile; fileRef = B69B50502726CD7F00758A2B /* atb-with-update.json */; }; B69B50552726CD8100758A2B /* invalid.json in Resources */ = {isa = PBXBuildFile; fileRef = B69B50512726CD8000758A2B /* invalid.json */; }; B69B50572727D16900758A2B /* AtbAndVariantCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */; }; - B6A22B392B19F91E00ECD2BA /* __Snapshots__ in Resources */ = {isa = PBXBuildFile; fileRef = B6A22B382B19F91E00ECD2BA /* __Snapshots__ */; }; - B6A22B3A2B19F91E00ECD2BA /* __Snapshots__ in Resources */ = {isa = PBXBuildFile; fileRef = B6A22B382B19F91E00ECD2BA /* __Snapshots__ */; }; B6A22B622B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; B6A22B632B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; B6A22B652B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; @@ -4058,6 +4057,7 @@ B66260E529ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHotkeyHandler.swift; sourceTree = ""; }; B662D3D82755D7AD0035D4D6 /* PixelStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelStoreTests.swift; sourceTree = ""; }; B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyStoreMock.swift; sourceTree = ""; }; + B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImportViewLocalizationTests.swift; sourceTree = ""; }; B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressBarTextEditor.swift; sourceTree = ""; }; B6685E3E29A606190043D2EE /* WorkspaceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceProtocol.swift; sourceTree = ""; }; B6685E4129A61C460043D2EE /* DownloadsTabExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadsTabExtension.swift; sourceTree = ""; }; @@ -4127,7 +4127,6 @@ B69B50502726CD7F00758A2B /* atb-with-update.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "atb-with-update.json"; sourceTree = ""; }; B69B50512726CD8000758A2B /* invalid.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = invalid.json; sourceTree = ""; }; B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AtbAndVariantCleanup.swift; sourceTree = ""; }; - B6A22B382B19F91E00ECD2BA /* __Snapshots__ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = __Snapshots__; sourceTree = ""; }; B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSummaryViewModel.swift; sourceTree = ""; }; B6A5A27025B9377300AA7ADA /* StatePersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatePersistenceService.swift; sourceTree = ""; }; B6A5A27825B93FFE00AA7ADA /* StateRestorationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorationManagerTests.swift; sourceTree = ""; }; @@ -5409,7 +5408,6 @@ 4B723DFE26B0003E00E14D75 /* DataImport */ = { isa = PBXGroup; children = ( - B6A22B382B19F91E00ECD2BA /* __Snapshots__ */, 373A1AB128451ED400586521 /* BookmarksHTMLImporterTests.swift */, 373A1AA9283ED86C00586521 /* BookmarksHTMLReaderTests.swift */, 4B3F641D27A8D3BD00E0C118 /* BrowserProfileTests.swift */, @@ -5423,6 +5421,7 @@ B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */, B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */, 4BB99D0D26FE1A83001E4761 /* FirefoxBookmarksReaderTests.swift */, + B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */, 4B98D27B28D960DD003C2B6F /* FirefoxFaviconsReaderTests.swift */, 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */, 4B2975982828285900187C4E /* FirefoxKeyReaderTests.swift */, @@ -8564,7 +8563,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B6A22B3A2B19F91E00ECD2BA /* __Snapshots__ in Resources */, 3706FE8B293F661700E42796 /* empty in Resources */, 376E2D282942843D001CD31B /* privacy-reference-tests in Resources */, 3706FE8C293F661700E42796 /* atb-with-update.json in Resources */, @@ -8800,7 +8798,6 @@ 31E163C0293A581900963C10 /* privacy-reference-tests in Resources */, B69B50542726CD8100758A2B /* atb-with-update.json in Resources */, 37A803DB27FD69D300052F4C /* DataImportResources in Resources */, - B6A22B392B19F91E00ECD2BA /* __Snapshots__ in Resources */, B69B50522726CD8100758A2B /* atb.json in Resources */, 4B70C00127B0793D000386ED /* DuckDuckGo-ExampleCrash.ips in Resources */, 4BCF15ED2ABB9B180083F6DF /* network-protection-messages.json in Resources */, @@ -9919,6 +9916,7 @@ 3706FE1E293F661700E42796 /* GeolocationProviderTests.swift in Sources */, 3706FE1F293F661700E42796 /* AppStateChangePublisherTests.swift in Sources */, 3706FE20293F661700E42796 /* CLLocationManagerMock.swift in Sources */, + B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, B6619EF72B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */, 3706FE21293F661700E42796 /* DownloadsPreferencesTests.swift in Sources */, 3706FE22293F661700E42796 /* FireproofDomainsTests.swift in Sources */, @@ -11867,6 +11865,7 @@ 1D1C36E329FAE8DA001FA40C /* FaviconManagerTests.swift in Sources */, 4B723E0526B0003E00E14D75 /* DataImportMocks.swift in Sources */, 4B70C00227B0793D000386ED /* CrashReportTests.swift in Sources */, + B6656E0D2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, 37D23787287F5C2900BCE03B /* PinnedTabsViewModelTests.swift in Sources */, 4B9DB0542A983B55000927DB /* MockWaitlistStorage.swift in Sources */, 4BF4EA5027C71F26004E57C4 /* PasswordManagementListSectionTests.swift in Sources */, diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index a66f73176e..f4061228f3 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -555,6 +555,11 @@ struct UserText { static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Passwords File", comment: "Title text for the CSV importer") static let importBookmarksHTML = NSLocalizedString("import.bookmarks.html.title", value: "HTML Bookmarks File", comment: "Title text for the HTML Bookmarks importer") + static let importBookmarksSelectHTMLFile = NSLocalizedString("import.bookmarks.select-html-file", value: "Select Bookmarks HTML File…", comment: "Button text for selecting HTML Bookmarks file") + static let importLoginsSelectCSVFile = NSLocalizedString("import.logins.select-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a CSV file") + static func importLoginsSelectCSVFile(from source: DataImport.Source) -> String { + String(format: NSLocalizedString("import.logins.select-csv-file.source", value: "Select %@ CSV File…", comment: "Button text for selecting a CSV file exported from (LastPass or Bitwarden or 1Password - %@)"), source.importSourceName) + } static let importLoginsPasswords = NSLocalizedString("import.logins.passwords", value: "Passwords", comment: "Title text for the Passwords import option") diff --git a/DuckDuckGo/Common/View/SwiftUI/SheetHostingWindow.swift b/DuckDuckGo/Common/View/SwiftUI/SheetHostingWindow.swift index d523df4e2b..5290733c81 100644 --- a/DuckDuckGo/Common/View/SwiftUI/SheetHostingWindow.swift +++ b/DuckDuckGo/Common/View/SwiftUI/SheetHostingWindow.swift @@ -20,7 +20,7 @@ import AppKit import Foundation import SwiftUI -final class SheetHostingWindow: NSWindow { +internal class SheetHostingWindow: NSWindow { init(rootView: Content) { super.init(contentRect: .zero, styleMask: [.titled, .closable, .docModalWindow], backing: .buffered, defer: false) diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 9fbb6435f8..1b99c5de52 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -22,7 +22,7 @@ import PixelKit enum DataImport { - enum Source: CaseIterable, Equatable { + enum Source: String, RawRepresentable, CaseIterable, Equatable { case brave case chrome case chromium @@ -347,7 +347,7 @@ enum DataImport { } -enum DataImportAction { +enum DataImportAction: String, RawRepresentable { case bookmarks case passwords case favicons diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 4761019cfb..346820679a 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -711,3 +711,42 @@ extension DataImportViewModel { } } + +extension DataImportViewModel: CustomStringConvertible { + + var description: String { + "DataImportViewModel(importSource: .\(importSource.rawValue), screen: \(screen)\(!summary.isEmpty ? ", summary: \(summary)" : ""))" + } + +} + +extension DataImportViewModel.Screen: CustomStringConvertible { + + var description: String { + switch self { + case .profileAndDataTypesPicker: ".profileAndDataTypesPicker" + case .moreInfo: ".moreInfo" + case .getReadPermission(let url): ".getReadPermission(\(fatalError()))" // TODO: getReadPermission + case .fileImport(dataType: let dataType, summary: let summaryDataTypes): ".fileImport(dataType: .\(dataType)\(!summaryDataTypes.isEmpty ? ", summary: [\(summaryDataTypes.map { "." + $0.rawValue }.joined(separator: ", "))]" : ""))" + case .summary(let dataTypes): ".summary([\(dataTypes.map { "." + $0.rawValue }.joined(separator: ", "))])" + case .feedback: ".feedback" + } + } + +} + +extension DataImportViewModel.DataTypeImportResult: CustomStringConvertible { + + var description: String { + ".init(.\(dataType), \(result))" + } + +} + +extension DataImport.DataTypeSummary: CustomStringConvertible { + + var description: String { + ".init(successful: \(successful), duplicate: \(duplicate), failed: \(failed))" + } + +} diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index f78baa7dc9..c3bf1fa8e2 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -20,6 +20,440 @@ import Common import SwiftUI import UniformTypeIdentifiers +// swiftlint:disable function_body_length cyclomatic_complexity +@InstructionsView.InstructionsBuilder +func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImport.DataType, button: @escaping (String) -> AnyView) -> [InstructionsView.InstructionsItem] { + + switch (source, dataType) { + case (.chrome, .passwords): + NSLocalizedString("import.csv.instructions.chrome", value: """ + %d Open **%s** + %d In a fresh tab, click %@ then **Google Password Manager → Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Google Chrome browser. + %N$d - step number + %2$s - browser name (Chrome) + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.brave, .passwords): + NSLocalizedString("import.csv.instructions.brave", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Password Manager** + %d Click %@ **at the top left** of the Password Manager and select **Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Brave browser. + %N$d - step number + %2$s - browser name (Brave) + %4$@, %6$@ - hamburger menu icon + %10$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuHamburger16 + button(UserText.importLoginsSelectCSVFile) + + case (.chromium, .passwords), + (.edge, .passwords): + NSLocalizedString("import.csv.instructions.chromium", value: """ + %d Open **%s** + %d In a fresh tab, click %@ then **Password Manager → Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.coccoc, .passwords): + NSLocalizedString("import.csv.instructions.coccoc", value: """ + %d Open **%s** + %d Type “_coccoc://settings/passwords_” into the Address field + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name (Cốc Cốc) + %5$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.opera, .passwords): + NSLocalizedString("import.csv.instructions.opera", value: """ + %d Open **%s** + %d Use the Menu Bar to select **View → Show Password Manager** + %d Select **Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name (Opera) + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile) + + case (.vivaldi, .passwords): + NSLocalizedString("import.csv.instructions.vivaldi", value: """ + %d Open **%s** + %d Type “_chrome://settings/passwords_” into the Address field + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords exported as CSV from Vivaldi browser. + %N$d - step number + %2$s - browser name (Vivaldi) + %5$@ - menu button icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.operaGX, .passwords): + NSLocalizedString("import.csv.instructions.operagx", value: """ + %d Open **%s** + %d Use the Menu Bar to select **View → Show Password Manager** + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name (Opera GX) + %5$@ - menu button icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.yandex, .passwords): + NSLocalizedString("import.csv.instructions.yandex", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Passwords and cards** + %d Click %@ then **Export passwords** + %d Choose **To a text file (not secure)** and click **Export** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Yandex Browser. + %N$d - step number + %2$s - browser name (Yandex) + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.brave, .bookmarks), + (.chrome, .bookmarks), + (.chromium, .bookmarks), + (.coccoc, .bookmarks), + (.edge, .bookmarks): + NSLocalizedString("import.html.instructions.chromium", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmark Manager** + %d Click %@ then **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Chromium-based browsers. + %N$d - step number + %2$s - browser name + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importBookmarksSelectHTMLFile) + + case (.vivaldi, .bookmarks): + NSLocalizedString("import.html.instructions.vivaldi", value: """ + %d Open **%s** + %d Use the Menu Bar to select **File → Export Bookmarks…** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Vivaldi browser. + %N$d - step number + %2$s - browser name (Vivaldi) + %6$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importBookmarksSelectHTMLFile) + + case (.opera, .bookmarks): + NSLocalizedString("import.html.instructions.opera", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmarks** + %d Click **Open full Bookmarks view…** in the bottom left + %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Opera browser. + %N$d - step number + %2$s - browser name (Opera) + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importBookmarksSelectHTMLFile) + + case (.operaGX, .bookmarks): + NSLocalizedString("import.html.instructions.operagx", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmarks** + %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Opera GX browser. + %N$d - step number + %2$s - browser name (Opera GX) + %7$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importBookmarksSelectHTMLFile) + + case (.yandex, .bookmarks): + NSLocalizedString("import.html.instructions.yandex", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Favorites → Bookmark Manager** + %d Click %@ then **Export bookmarks to HTML file** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Yandex Browser. + %N$d - step number + %2$s - browser name (Yandex) + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importBookmarksSelectHTMLFile) + + case (.safari, .passwords), (.safariTechnologyPreview, .passwords): + NSLocalizedString("import.csv.instructions.safari", value: """ + %d Open **Safari** + %d Select **File → Export → Passwords** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Safari. + %N$d - step number + %5$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + button(UserText.importLoginsSelectCSVFile) + + case (.safari, .bookmarks), (.safariTechnologyPreview, .bookmarks): + NSLocalizedString("import.html.instructions.safari", value: """ + %d Open **Safari** + %d Select **File → Export → Bookmarks** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Safari. + %N$d - step number + %5$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + button(UserText.importBookmarksSelectHTMLFile) + + case (.firefox, .passwords): + NSLocalizedString("import.csv.instructions.firefox", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Passwords** + %d Click %@ then **Export Logins…** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Firefox. + %N$d - step number + %2$s - browser name (Firefox) + %4$@, %6$@ - hamburger menu icon + %9$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuHorizontal16 + button(UserText.importLoginsSelectCSVFile) + + case (.firefox, .bookmarks), (.tor, .bookmarks): + NSLocalizedString("import.html.instructions.firefox", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Manage Bookmarks** + %d Click %@ then **Export bookmarks to HTML…** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Firefox based browsers. + %N$d - step number + %2$s - browser name (Firefox) + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.importExport16 + button(UserText.importBookmarksSelectHTMLFile) + + case (.onePassword8, .passwords): + NSLocalizedString("import.csv.instructions.onePassword8", value: """ + %d Open and unlock **%s** + %d Select **File → Export** from the Menu Bar and choose the account you want to export + %d Enter your 1Password account password + %d Select the File Format: **CSV (Logins and Passwords only)** + %d Click Export Data and save the file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from 1Password 8. + %2$s - app name (1Password) + %8$@ - “Select 1Password CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.onePassword7, .passwords): + NSLocalizedString("import.csv.instructions.onePassword7", value: """ + %d Open and unlock **%s** + %d Select the vault you want to Export (You cannot export from “All Vaults.”) + %d Select **File → Export → All Items** from the Menu Bar + %d Enter your 1Password master or account password + %d Select the File Format: **iCloud Keychain (.csv)** + %d Save the passwords file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from 1Password 7. + %2$s - app name (1Password) + %9$@ - “Select 1Password CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.bitwarden, .passwords): + NSLocalizedString("import.csv.instructions.bitwarden", value: """ + %d Open and unlock **%s** + %d Select **File → Export vault** from the Menu Bar + %d Select the File Format: **.csv** + %d Enter your Bitwarden Master password + %d Click %@ and save the file someplace you can find it (e.g. Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Bitwarden. + %2$s - app name (Bitwarden) + %7$@ - hamburger menu icon + %9$@ - “Select Bitwarden CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage(systemSymbolName: "square.and.arrow.down", accessibilityDescription: nil) ?? .downloads + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.lastPass, .passwords): + NSLocalizedString("import.csv.instructions.lastpass", value: """ + %d Click on the **%s** icon in your browser and enter your master password + %d Select **Open My Vault** + %d From the sidebar select **Advanced Options → Export** + %d Enter your LastPass master password + %d Select the File Format: **Comma Delimited Text (.csv)** + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from LastPass. + %2$s - app name (LastPass) + %8$@ - “Select LastPass CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.csv, .passwords): + NSLocalizedString("import.csv.instructions.generic", value: """ + The CSV importer will try to match column headers to their position. + If there is no header, it supports two formats: + %d URL, Username, Password + %d Title, URL, Username, Password + %@ + """, comment: """ + Instructions to import a generic CSV passwords file. + %N$d - step number + %3$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + button(UserText.importLoginsSelectCSVFile) + + case (.bookmarksHTML, .bookmarks): + NSLocalizedString("import.html.instructions.generic", value: """ + %d Open your old browser + %d Open **Bookmark Manager** + %d Export bookmarks to HTML… + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import a generic HTML Bookmarks file. + %N$d - step number + %6$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + button(UserText.importBookmarksSelectHTMLFile) + + case (.bookmarksHTML, .passwords), + (.tor, .passwords), + (.onePassword7, .bookmarks), + (.onePassword8, .bookmarks), + (.bitwarden, .bookmarks), + (.lastPass, .bookmarks), + (.csv, .bookmarks): + assertionFailure("Invalid source/dataType") + } +} +// swiftlint:enable function_body_length cyclomatic_complexity + struct FileImportView: View { let source: DataImport.Source @@ -69,443 +503,17 @@ struct FileImportView: View { } InstructionsView { - switch (source, dataType) { - case (.chrome, .passwords): - NSLocalizedString("import.csv.instructions.chrome", value: """ - %d Open **%s** - %d In a fresh tab, click %@ then **Google Password Manager → Settings** - %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Google Chrome browser. - %N$d - step number - %2$s - browser name (Chrome) - %4$@ - hamburger menu icon - %8$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuVertical16 - button("Select Passwords CSV File…") - - case (.brave, .passwords): - NSLocalizedString("import.csv.instructions.brave", value: """ - %d Open **%s** - %d Click %@ to open the application menu then click **Password Manager** - %d Click %@ **at the top left** of the Password Manager and select **Settings** - %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Brave browser. - %N$d - step number - %2$s - browser name (Brave) - %4$@, %6$@ - hamburger menu icon - %10$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuHamburger16 - NSImage.menuHamburger16 - button("Select Passwords CSV File…") - - case (.chromium, .passwords), - (.edge, .passwords): - NSLocalizedString("import.csv.instructions.chromium", value: """ - %d Open **%s** - %d In a fresh tab, click %@ then **Password Manager → Settings** - %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Chromium-based browsers. - %N$d - step number - %2$s - browser name - %4$@ - hamburger menu icon - %8$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuVertical16 - button("Select Passwords CSV File…") - - case (.coccoc, .passwords): - NSLocalizedString("import.csv.instructions.coccoc", value: """ - %d Open **%s** - %d Type “_coccoc://settings/passwords_” into the Address field - %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Chromium-based browsers. - %N$d - step number - %2$s - browser name (Cốc Cốc) - %5$@ - hamburger menu icon - %8$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuVertical16 - button("Select Passwords CSV File…") - - case (.opera, .passwords): - NSLocalizedString("import.csv.instructions.opera", value: """ - %d Open **%s** - %d Use the Menu Bar to select **View → Show Password Manager** - %d Select **Settings** - %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Chromium-based browsers. - %N$d - step number - %2$s - browser name (Opera) - %8$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - button("Select Passwords CSV File…") - - case (.vivaldi, .passwords): - NSLocalizedString("import.csv.instructions.vivaldi", value: """ - %d Open **%s** - %d Type “_chrome://settings/passwords_” into the Address field - %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords exported as CSV from Vivaldi browser. - %N$d - step number - %2$s - browser name (Vivaldi) - %5$@ - menu button icon - %8$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuVertical16 - button("Select Passwords CSV File…") - - case (.operaGX, .passwords): - NSLocalizedString("import.csv.instructions.operagx", value: """ - %d Open **%s** - %d Use the Menu Bar to select **View → Show Password Manager** - %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Chromium-based browsers. - %N$d - step number - %2$s - browser name (Opera GX) - %5$@ - menu button icon - %8$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuVertical16 - button("Select Passwords CSV File…") - - case (.yandex, .passwords): - NSLocalizedString("import.csv.instructions.yandex", value: """ - %d Open **%s** - %d Click %@ to open the application menu then click **Passwords and cards** - %d Click %@ then **Export passwords** - %d Choose **To a text file (not secure)** and click **Export** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Yandex Browser. - %N$d - step number - %2$s - browser name (Yandex) - %4$@ - hamburger menu icon - %8$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuHamburger16 - NSImage.menuVertical16 - button("Select Passwords CSV File…") - - case (.brave, .bookmarks), - (.chrome, .bookmarks), - (.chromium, .bookmarks), - (.coccoc, .bookmarks), - (.edge, .bookmarks): - NSLocalizedString("import.html.instructions.chromium", value: """ - %d Open **%s** - %d Use the Menu Bar to select **Bookmarks → Bookmark Manager** - %d Click %@ then **Export Bookmarks** - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import Bookmarks exported as HTML from Chromium-based browsers. - %N$d - step number - %2$s - browser name - %5$@ - hamburger menu icon - %8$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuVertical16 - button("Select Bookmarks HTML File…") - - case (.vivaldi, .bookmarks): - NSLocalizedString("import.html.instructions.vivaldi", value: """ - %d Open **%s** - %d Use the Menu Bar to select **File → Export Bookmarks…** - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import Bookmarks exported as HTML from Vivaldi browser. - %N$d - step number - %2$s - browser name (Vivaldi) - %6$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - source.importSourceName - button("Select Bookmarks HTML File…") - - case (.opera, .bookmarks): - NSLocalizedString("import.html.instructions.opera", value: """ - %d Open **%s** - %d Use the Menu Bar to select **Bookmarks → Bookmarks** - %d Click **Open full Bookmarks view…** in the bottom left - %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import Bookmarks exported as HTML from Opera browser. - %N$d - step number - %2$s - browser name (Opera) - %8$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - source.importSourceName - button("Select Bookmarks HTML File…") - - case (.operaGX, .bookmarks): - NSLocalizedString("import.html.instructions.operagx", value: """ - %d Open **%s** - %d Use the Menu Bar to select **Bookmarks → Bookmarks** - %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import Bookmarks exported as HTML from Opera GX browser. - %N$d - step number - %2$s - browser name (Opera GX) - %7$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - source.importSourceName - button("Select Bookmarks HTML File…") - - case (.yandex, .bookmarks): - NSLocalizedString("import.html.instructions.yandex", value: """ - %d Open **%s** - %d Use the Menu Bar to select **Favorites → Bookmark Manager** - %d Click %@ then **Export bookmarks to HTML file** - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import Bookmarks exported as HTML from Yandex Browser. - %N$d - step number - %2$s - browser name (Yandex) - %5$@ - hamburger menu icon - %8$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuVertical16 - button("Select Bookmarks HTML File…") - - case (.safari, .passwords), (.safariTechnologyPreview, .passwords): - NSLocalizedString("import.csv.instructions.safari", value: """ - %d Open **Safari** - %d Select **File → Export → Passwords** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Safari. - %N$d - step number - %5$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - - button("Select Passwords CSV File…") - - case (.safari, .bookmarks), (.safariTechnologyPreview, .bookmarks): - NSLocalizedString("import.html.instructions.safari", value: """ - %d Open **Safari** - %d Select **File → Export → Bookmarks** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Bookmarks exported as HTML from Safari. - %N$d - step number - %5$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - button("Select Bookmarks HTML File…") - - case (.firefox, .passwords): - NSLocalizedString("import.csv.instructions.firefox", value: """ - %d Open **%s** - %d Click %@ to open the application menu then click **Passwords** - %d Click %@ then **Export Logins…** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Firefox. - %N$d - step number - %2$s - browser name (Firefox) - %4$@, %6$@ - hamburger menu icon - %9$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.menuHamburger16 - NSImage.menuHorizontal16 - button("Select Passwords CSV File…") - - case (.firefox, .bookmarks), (.tor, .bookmarks): - NSLocalizedString("import.html.instructions.firefox", value: """ - %d Open **%s** - %d Use the Menu Bar to select **Bookmarks → Manage Bookmarks** - %d Click %@ then **Export bookmarks to HTML…** - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import Bookmarks exported as HTML from Firefox based browsers. - %N$d - step number - %2$s - browser name (Firefox) - %5$@ - hamburger menu icon - %8$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage.importExport16 - button("Select Bookmarks HTML File…") - - case (.onePassword8, .passwords): - NSLocalizedString("import.csv.instructions.onePassword8", value: """ - %d Open and unlock **%s** - %d Select **File → Export** from the Menu Bar and choose the account you want to export - %d Enter your 1Password account password - %d Select the File Format: **CSV (Logins and Passwords only)** - %d Click Export Data and save the file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from 1Password 8. - %2$s - app name (1Password) - %8$@ - “Select 1Password CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - button("Select 1Password CSV File…") - - case (.onePassword7, .passwords): - NSLocalizedString("import.csv.instructions.onePassword7", value: """ - %d Open and unlock **%s** - %d Select the vault you want to Export (You cannot export from “All Vaults.”) - %d Select **File → Export → All Items** from the Menu Bar - %d Enter your 1Password master or account password - %d Select the File Format: **iCloud Keychain (.csv)** - %d Save the passwords file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from 1Password 7. - %2$s - app name (1Password) - %9$@ - “Select 1Password CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - button("Select 1Password CSV File…") - - case (.bitwarden, .passwords): - NSLocalizedString("import.csv.instructions.bitwarden", value: """ - %d Open and unlock **%s** - %d Select **File → Export vault** from the Menu Bar - %d Select the File Format: **.csv** - %d Enter your Bitwarden Master password - %d Click %@ and save the file someplace you can find it (e.g. Desktop) - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from Bitwarden. - %2$s - app name (Bitwarden) - %7$@ - hamburger menu icon - %9$@ - “Select Bitwarden CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - NSImage(systemSymbolName: "square.and.arrow.down", accessibilityDescription: nil) ?? .downloads - button("Select Bitwarden CSV File…") - - case (.lastPass, .passwords): - NSLocalizedString("import.csv.instructions.lastpass", value: """ - %d Click on the **%s** icon in your browser and enter your master password - %d Select **Open My Vault** - %d From the sidebar select **Advanced Options → Export** - %d Enter your LastPass master password - %d Select the File Format: **Comma Delimited Text (.csv)** - %d %@ - """, comment: """ - Instructions to import Passwords as CSV from LastPass. - %2$s - app name (LastPass) - %8$@ - “Select LastPass CSV File” button - **bold text**; _italic text_ - """) - source.importSourceName - button("Select LastPass CSV File…") - - case (.csv, .passwords): - NSLocalizedString("import.csv.instructions.generic", value: """ - The CSV importer will try to match column headers to their position. - If there is no header, it supports two formats: - %d URL, Username, Password - %d Title, URL, Username, Password - %@ - """, comment: """ - Instructions to import a generic CSV passwords file. - %N$d - step number - %3$@ - “Select Passwords CSV File” button - **bold text**; _italic text_ - """) - - button("Select Passwords CSV File…") - - case (.bookmarksHTML, .bookmarks): - NSLocalizedString("import.html.instructions.generic", value: """ - %d Open your old browser - %d Open **Bookmark Manager** - %d Export bookmarks to HTML… - %d Save the file someplace you can find it (e.g., Desktop) - %d %@ - """, comment: """ - Instructions to import a generic HTML Bookmarks file. - %N$d - step number - %6$@ - “Select Bookmarks HTML File” button - **bold text**; _italic text_ - """) - button("Select Bookmarks HTML File…") - - case (.bookmarksHTML, .passwords), - (.tor, .passwords), - (.onePassword7, .bookmarks), - (.onePassword8, .bookmarks), - (.bitwarden, .bookmarks), - (.lastPass, .bookmarks), - (.csv, .bookmarks): - assertionFailure("Invalid source/dataType") - } + fileImportInstructionsBuilder(source: source, dataType: dataType, button: self.button) } } } - private func button(_ localizedTitleKey: LocalizedStringKey) -> some View { - Button(localizedTitleKey, action: action) - .onDrop(of: dataType.allowedFileTypes, isTargeted: nil, perform: onDrop) - .disabled(isButtonDisabled) + private func button(_ title: String) -> AnyView { + AnyView( + Button(title, action: action) + .onDrop(of: dataType.allowedFileTypes, isTargeted: nil, perform: onDrop) + .disabled(isButtonDisabled) + ) } private func onDrop(_ providers: [NSItemProvider], _ location: CGPoint) -> Bool { @@ -670,7 +678,7 @@ struct InstructionsView: View { } assert(usedArgs.subtracting(IndexSet(args.indices)).isEmpty, "Unused arguments at indices \(usedArgs.subtracting(IndexSet(args.indices)))") - +// TODO: add tests for each localization and import source combination self.instructions = result } catch { diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 03bdb96bb7..6926555010 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -3429,7 +3429,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "HTML Bookmarks File (for other browsers)" + "value" : "HTML Bookmarks File" } } } @@ -3470,6 +3470,18 @@ } } }, + "import.bookmarks.select-html-file" : { + "comment" : "Button text for selecting HTML Bookmarks file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Bookmarks HTML File…" + } + } + } + }, "import.browser.data" : { "comment" : "Import Browser Data dialog title", "extractionState" : "extracted_with_value", @@ -3885,7 +3897,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "CSV Passwords File (for other browsers)" + "value" : "CSV Passwords File" } } } @@ -3902,6 +3914,30 @@ } } }, + "import.logins.select-csv-file" : { + "comment" : "Button text for selecting a CSV file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Passwords CSV File…" + } + } + } + }, + "import.logins.select-csv-file.source" : { + "comment" : "Button text for selecting a CSV file exported from (LastPass or Bitwarden or 1Password - %@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select %@ CSV File…" + } + } + } + }, "import.passwords.indefinite.progress.text" : { "comment" : "Operation progress info message about indefinite number of passwords being imported", "extractionState" : "extracted_with_value", @@ -7908,24 +7944,15 @@ }, "Select %@ Folder…" : { - }, - "Select 1Password CSV File…" : { - }, "Select All" : { "comment" : "Main Menu Edit item" - }, - "Select Bitwarden CSV File…" : { - }, "Select Bookmarks HTML File…" : { }, "Select data to import:" : { "comment" : "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks." - }, - "Select LastPass CSV File…" : { - }, "Select Passwords CSV File…" : { diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift index 2f3c2d6b82..b009f3f7a3 100644 --- a/UnitTests/DataImport/DataImportViewModelTests.swift +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -33,13 +33,13 @@ final class DataImportViewModelTests: XCTestCase { override func setUp() { model = nil - importTask = nil +// importTask = nil // TODO: remove me OSLog.loggingCategories.insert(OSLog.AppCategories.dataImportExport.rawValue) } - func setupModel(with source: Source, profiles: [(ThirdPartyBrowser) -> BrowserProfile], screen: DataImportViewModel.Screen? = nil, summary: DataImportViewModel.DataImportViewSummary = .init()) { + func setupModel(with source: Source, profiles: [(ThirdPartyBrowser) -> BrowserProfile], screen: DataImportViewModel.Screen? = nil, summary: [DataImportViewModel.DataTypeImportResult] = []) { model = DataImportViewModel(importSource: source, screen: screen, summary: summary, loadProfiles: { browser in .init(browser: browser, profiles: profiles.map { $0(browser) }) { profile in { @@ -147,6 +147,24 @@ final class DataImportViewModelTests: XCTestCase { // TODO: } + func testDataImportSourceSupportedDataTypes() { + for source in Source.allCases { + if source.initialScreen == .profileAndDataTypesPicker { + if source == .tor { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) + } else { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks, .passwords], source.importSourceName) + } + } else { + if source == .bookmarksHTML { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) + } else { + XCTAssertEqual(source.supportedDataTypes, [.passwords], source.importSourceName) + } + } + } + } + func testWhenBrowserPasswordsImportFails_manualImportSuggested() async throws { for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { guard let browser = ThirdPartyBrowser.browser(for: source) else { @@ -155,33 +173,36 @@ final class DataImportViewModelTests: XCTestCase { } setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) -// TODO: tor does not support passwords - try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: browser), resultingWith: [ + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ .bookmarks: .success(.init(successful: 10, duplicate: 0, failed: 0)), - .passwords: .failure(MockImportError(action: .passwords, errorType: .decryptionError)) + .passwords: .failure(Failure(.passwords, .decryptionError)) ]) - expect(.error(dataType: .passwords, errorType: .decryptionError), actionButton: .manualImport, secondaryButton: .skip, "\(source)") + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) } } - func testWhenManualImportChosenForPasswords_csvFileImportScreenIsShown() async throws { - for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { - guard let browser = ThirdPartyBrowser.browser(for: source) else { - XCTFail("no ThirdPartyBrowser for \(source)") - continue - } + - setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], screen: .error(dataType: .passwords, errorType: .decryptionError), summary: [ - .init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), - .init(.passwords, .failure(MockImportError(action: .passwords, errorType: .decryptionError))) - ]) - - model.performAction(.manualImport) - // TODO: tor does not support passwords - expect(.fileImport(.passwords), actionButton: .skip, secondaryButton: .back, "\(source)") - } - } +// func testWhenManualImportChosenForPasswords_csvFileImportScreenIsShown() async throws { +// for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { +// guard let browser = ThirdPartyBrowser.browser(for: source) else { +// XCTFail("no ThirdPartyBrowser for \(source)") +// continue +// } +// +// setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], screen: .error(dataType: .passwords, errorType: .decryptionError), summary: [ +// .init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), +// .init(.passwords, .failure(MockImportError(action: .passwords, errorType: .decryptionError))) +// ]) +// +// model.performAction(.manualImport) +// // TODO: tor does not support passwords +// expect(.fileImport(.passwords), actionButton: .skip, secondaryButton: .back, "\(source)") +// } +// } // try await testBranch { // try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [ @@ -198,49 +219,47 @@ final class DataImportViewModelTests: XCTestCase { // model.performAction(.skip) // expect(.summary, actionButton: .done, secondaryButton: .back, "\(source) - Skip") // } - - - func testBrowserDataImport() async throws { - for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { - guard let browser = ThirdPartyBrowser.browser(for: source) else { - XCTFail("no ThirdPartyBrowser for \(source)") - continue - } - - setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) - - try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: browser), resultingWith: [ - .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), - .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)) - ]) - - expect(.error(dataType: .bookmarks, errorType: .noData), actionButton: .manualImport, secondaryButton: .skip, "\(source)") - } - } - - func testGenericDataImport() async { - for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil || source.initialScreen != .profileAndDataTypesPicker { - model = DataImportViewModel(importSource: source, loadProfiles: { XCTFail("Unexpected loadProfiles"); return .init(browser: $0, profiles: []) }, dataImporterFactory: dataImporter) - XCTAssertEqual(source.supportedDataTypes.count, 1) - expect(.fileImport(source.supportedDataTypes.first!), actionButton: .initiateImport(disabled: true), secondaryButton: .cancel) - - - } - } +// +// func testBrowserDataImport() async throws { +// for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { +// guard let browser = ThirdPartyBrowser.browser(for: source) else { +// XCTFail("no ThirdPartyBrowser for \(source)") +// continue +// } +// +// setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) +// +// try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: browser), resultingWith: [ +// .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), +// .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)) +// ]) +// +// expect(.error(dataType: .bookmarks, errorType: .noData), actionButton: .manualImport, secondaryButton: .skip, "\(source)") +// } +// } +// +// func testGenericDataImport() async { +// for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil || source.initialScreen != .profileAndDataTypesPicker { +// model = DataImportViewModel(importSource: source, loadProfiles: { XCTFail("Unexpected loadProfiles"); return .init(browser: $0, profiles: []) }, dataImporterFactory: dataImporter) +// XCTAssertEqual(source.supportedDataTypes.count, 1) +// expect(.fileImport(source.supportedDataTypes.first!), actionButton: .initiateImport(disabled: true), secondaryButton: .cancel) +// +// } +// } // TODO: test skip // MARK: - Tests - func testWhenPreferredImportSourcesAvailable_firstPreferredSourceIsSelected() { - model = DataImportViewModel(availableImportSources: [.safari, .csv, .bitwarden], preferredImportSources: [.firefox, .chrome, .bitwarden, .safari]) - XCTAssertEqual(model.importSource, .bitwarden) - } - - func testWhenModelIsInstantiated_initialScreenIsShown() { - for source in Source.allCases { - model = DataImportViewModel(importSource: source) - XCTAssertEqual(model.screen, source.initialScreen, "\(source)") - } - } +// func testWhenPreferredImportSourcesAvailable_firstPreferredSourceIsSelected() { +// model = DataImportViewModel(availableImportSources: [.safari, .csv, .bitwarden], preferredImportSources: [.firefox, .chrome, .bitwarden, .safari]) +// XCTAssertEqual(model.importSource, .bitwarden) +// } +// +// func testWhenModelIsInstantiated_initialScreenIsShown() { +// for source in Source.allCases { +// model = DataImportViewModel(importSource: source) +// XCTAssertEqual(model.screen, source.initialScreen, "\(source)") +// } +// } // func testWhenNextButtonIsClicked_screenForTheButtonIsShown() { // model = DataImportViewModel(importSource: .safari) @@ -504,7 +523,7 @@ final class DataImportViewModelTests: XCTestCase { private func dataImporter(for source: DataImport.Source, fileDataType: DataImport.DataType?, url: URL, primaryPassword: String?) -> DataImporter { XCTAssertEqual(source, model.importSource) - if case .fileImport(let dataType) = model.screen { + if case .fileImport(let dataType, summary: _) = model.screen { XCTAssertEqual(dataType, fileDataType) } else { XCTAssertNil(fileDataType) @@ -514,9 +533,9 @@ final class DataImportViewModelTests: XCTestCase { return ImporterMock(password: primaryPassword, importTask: self.importTask) } - func expectButtons(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) where T : Equatable { - - } +// func expectButtons(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) where T : Equatable { +// +// } } @@ -541,7 +560,7 @@ private class ImporterMock: DataImporter { var accessValidator: ((Set) -> [DataImport.DataType: any DataImportError]?)? - func validateAccess(for types: Set) -> [DataImport.DataType : any DataImportError]? { + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { accessValidator?(types) } @@ -584,7 +603,7 @@ private extension DataImport.BrowserProfile { } } -private struct MockImportError: DataImportError, CustomStringConvertible { +private struct Failure: DataImportError, CustomStringConvertible { enum OperationType: Int { case failure @@ -597,9 +616,15 @@ private struct MockImportError: DataImportError, CustomStringConvertible { var errorType: DataImport.ErrorType = .other + init(_ action: DataImportAction, _ errorType: DataImport.ErrorType) { + self.action = action + self.errorType = errorType + } + var description: String { - "Error(\(type.rawValue): \(errorType))" + "Failure(.\(action.rawValue), .\(errorType))" } + } extension DataImportViewModel.ButtonType: CustomStringConvertible { @@ -612,7 +637,6 @@ extension DataImportViewModel.ButtonType: CustomStringConvertible { case .back: "back" case .done: "done" case .submit: "submit" - case .manualImport: "manualImport" } } } @@ -623,10 +647,8 @@ extension DataImportViewModel.Screen: CustomStringConvertible { case .profileAndDataTypesPicker: "profileAndDataTypesPicker" case .moreInfo: "moreInfo" case .getReadPermission(let url): "getReadPermission(\(url.path))" - case .error(dataType: let dataType, errorType: let errorType): "error(\(dataType): \(errorType))" - case .fileImport(let dataType): "fileImport(\(dataType))" - case .fileImportSummary(let dataType): "fileImportSummary(\(dataType))" - case .summary: "summary" + case .fileImport(let dataType, summary: let summary): "fileImport(\(dataType), \(summary))" + case .summary(let dataTypes): "summary(\(dataTypes))" case .feedback: "feedback" } } diff --git a/UnitTests/DataImport/FileImportViewLocalizationTests.swift b/UnitTests/DataImport/FileImportViewLocalizationTests.swift new file mode 100644 index 0000000000..c840143d13 --- /dev/null +++ b/UnitTests/DataImport/FileImportViewLocalizationTests.swift @@ -0,0 +1,137 @@ +// +// FileImportViewLocalizationTests.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +import SwiftUI +@testable import DuckDuckGo_Privacy_Browser + +class YourLocalizationTests: XCTestCase { + + override func tearDown() { + Bundle.resetSwizzling() + } + + func testLocalizedStringForEnglish() { + var referenceValues = [DataImport.Source: [DataImport.DataType: [InstructionsView.InstructionsItem]]]() + for locale in [.base] + Bundle.main.availableLocalizations().filter({ $0 != .base }) { + setLocale(locale) + + for source in DataImport.Source.allCases { + for dataType in source.supportedDataTypes { + let e = expectation(description: "button factory called") + let items = fileImportInstructionsBuilder(source: source, dataType: dataType) { title in + XCTAssertFalse(title.isEmpty) + e.fulfill() + return AnyView(EmptyView()) + } + waitForExpectations(timeout: 0) + + if locale == .base { + referenceValues[source, default: [:]][dataType, default: []] = items + } else { + XCTAssertEqual(Set(referenceValues[source]![dataType]!), Set(items), "\(locale).\(source.rawValue).\(dataType.rawValue)") + } + } + } + } + } + + // Helper function to set the application's locale for testing + private func setLocale(_ identifier: String) { + let bundlePath = Bundle.mainBundle.path(forResource: identifier, ofType: "lproj")! + let testBundle = Bundle(path: bundlePath)! + Bundle.swizzleMainBundle(with: testBundle) + } +} + +extension InstructionsView.InstructionsItem: Hashable, CustomStringConvertible { + + public var description: String { + switch self { + case .string(let string): ".string(\"\(string)\")" + case .image(let image): ".image(\(image.representations.first!))" + case .view: ".view" + } + } + + public static func == (lhs: DuckDuckGo_Privacy_Browser.InstructionsView.InstructionsItem, rhs: DuckDuckGo_Privacy_Browser.InstructionsView.InstructionsItem) -> Bool { + switch lhs { + case .string: if case .string = rhs { return true } + case .image(let value1): if case .image(let value2) = rhs { return value1.tiffRepresentation! == value2.tiffRepresentation } + case .view: if case .view = rhs { return true } + } + return false + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .string: + hasher.combine(1) + case .image(let image): + hasher.combine(2) + hasher.combine(image.tiffRepresentation!) + case .view: + hasher.combine(3) + } + } + +} + +private extension String { + static let base = "Base" +} + +private extension Bundle { + + static var mainBundle: Bundle = .main + static var testBundle: Bundle? + + func availableLocalizations() -> [String] { + try! FileManager.default.contentsOfDirectory(atPath: resourcePath!).compactMap { + guard $0.hasSuffix(".lproj") else { return nil } + return $0.dropping(suffix: ".lproj") + } + } + + static func swizzleMainBundle(with bundle: Bundle) { + if testBundle == nil { + mainBundle = Bundle.main + swizzleMainBundle() + } + + testBundle = bundle + } + + static func resetSwizzling() { + guard testBundle != nil else { return } + swizzleMainBundle() + } + + private static func swizzleMainBundle() { + let originalMethod = class_getClassMethod(self, #selector(getter: Bundle.main))! + let swizzledMethod = class_getClassMethod(self, #selector(getter: swizzledMain))! + + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + @objc dynamic static var swizzledMain: Bundle { + testBundle! + } + +} diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt deleted file mode 100644 index 9c0d2ba4c2..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.1.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.brave - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.brave - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.brave - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.brave - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-brave - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.brave - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt deleted file mode 100644 index c402d2a428..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.10.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.safariTechnologyPreview - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.safariTechnologyPreview - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.safariTechnologyPreview - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.safariTechnologyPreview - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-safari-technology-preview - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.safariTechnologyPreview - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt deleted file mode 100644 index a7f5ea1373..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.11.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.tor - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.tor - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.tor - - chromiumPreferences: Optional.none - - fallbackProfileName: "default-release" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/default-release - ▿ BrowserProfile - - browser: ThirdPartyBrowser.tor - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-tor - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.tor - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt deleted file mode 100644 index c114086b18..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.12.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.vivaldi - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.vivaldi - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.vivaldi - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.vivaldi - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-vivaldi - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.vivaldi - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt deleted file mode 100644 index 3fbc6eef82..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.13.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.yandex - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.yandex - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.yandex - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.yandex - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-yandex - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.yandex - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt deleted file mode 100644 index 48134acd8d..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.2.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.chrome - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.chrome - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.chrome - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.chrome - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-chrome - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.chrome - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt deleted file mode 100644 index 4c002e4981..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.3.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.chromium - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.chromium - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.chromium - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.chromium - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-chromium - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.chromium - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt deleted file mode 100644 index ead9a218a4..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.4.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.coccoc - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.coccoc - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.coccoc - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.coccoc - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-coccoc - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.coccoc - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt deleted file mode 100644 index e9d4a95f6c..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.5.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.edge - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.edge - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.edge - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.edge - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-edge - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.edge - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt deleted file mode 100644 index 1f0e3a1739..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.6.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.firefox - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.firefox - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.firefox - - chromiumPreferences: Optional.none - - fallbackProfileName: "default-release" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/default-release - ▿ BrowserProfile - - browser: ThirdPartyBrowser.firefox - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-firefox - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.firefox - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt deleted file mode 100644 index 80f8d3ce94..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.7.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.opera - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.opera - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.opera - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.opera - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-opera - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.opera - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt deleted file mode 100644 index 26c5476ba2..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.8.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.operaGX - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.operaGX - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.operaGX - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.operaGX - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-operagx - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.operaGX - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - - userReportText: "" diff --git a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt b/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt deleted file mode 100644 index c9695257b4..0000000000 --- a/UnitTests/DataImport/__Snapshots__/DataImportViewModelTests/testWhenBrowserPasswordsImportFails_manualImportSuggested.9.txt +++ /dev/null @@ -1,68 +0,0 @@ -▿ Optional - ▿ some: DataImportViewModel - ▿ _successfulImportHappened: UserDefaultsWrapper> - - customUserDefaults: Optional.none - - defaultValue: Optional.none - - key: Key.homePageContinueSetUpImport - - setIfEmpty: false - ▿ browserProfiles: Optional - ▿ some: BrowserProfileList - - browser: ThirdPartyBrowser.safari - ▿ profiles: 3 elements - ▿ BrowserProfile - - browser: ThirdPartyBrowser.safari - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ BrowserProfile - - browser: ThirdPartyBrowser.safari - - chromiumPreferences: Optional.none - - fallbackProfileName: "Default" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Default - ▿ BrowserProfile - - browser: ThirdPartyBrowser.safari - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile 2" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile%202 - - validateProfileData: (Function) - - dataImporterFactory: (Function) - - importSource: source-safari - - importTask: Optional>, Never, DataImportProgressEvent>>.none - - loadProfiles: (Function) - - openPanelCallback: (Function) - - reportSenderFactory: (Function) - - requestPrimaryPasswordCallback: (Function) - ▿ screen: error(passwords: decryptionError) - ▿ error: (2 elements) - - dataType: passwords - - errorType: decryptionError - ▿ selectedDataTypes: 2 members - - bookmarks - - passwords - ▿ selectedProfile: Optional - ▿ some: BrowserProfile - - browser: ThirdPartyBrowser.safari - - chromiumPreferences: Optional.none - - fallbackProfileName: "Test Profile" - - fileStore: - - profileURL: file:///Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/Test%20Profile - ▿ summary: 2 elements - ▿ DataTypeImportResult - - dataType: bookmarks - ▿ result: .success(DataTypeSummary(successful: 10, duplicate: 0, failed: 0)) - ▿ success: DataTypeSummary - - duplicate: 0 - - failed: 0 - - successful: 10 - ▿ DataTypeImportResult - - dataType: passwords - ▿ result: .success(Error(0: decryptionError)) - ▿ failure: Error(0: decryptionError) - - action: logins - - type: OperationType.failure - - underlyingError: Optional.none - - errorType: decryptionError - - userReportText: "" From e005c99334a8cc53b2635749b9fd307d39c4481f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 13 Dec 2023 19:03:44 +0600 Subject: [PATCH 54/83] FileImportView instructions localization tests --- .../FileImportViewLocalizationTests.swift | 68 ++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/UnitTests/DataImport/FileImportViewLocalizationTests.swift b/UnitTests/DataImport/FileImportViewLocalizationTests.swift index c840143d13..71ee2ee99c 100644 --- a/UnitTests/DataImport/FileImportViewLocalizationTests.swift +++ b/UnitTests/DataImport/FileImportViewLocalizationTests.swift @@ -21,32 +21,84 @@ import XCTest import SwiftUI @testable import DuckDuckGo_Privacy_Browser +@available(macOS 13.0, *) class YourLocalizationTests: XCTestCase { override func tearDown() { Bundle.resetSwizzling() + customAssert = nil + customAssertionFailure = nil } - func testLocalizedStringForEnglish() { - var referenceValues = [DataImport.Source: [DataImport.DataType: [InstructionsView.InstructionsItem]]]() + func testLocalizedStringForEnglish() throws { + // collect reference "Base" localization escaped format arguments + var referenceValues = [DataImport.Source: [DataImport.DataType: [String]]]() for locale in [.base] + Bundle.main.availableLocalizations().filter({ $0 != .base }) { + // swizzle "locale".lproj bundle setLocale(locale) for source in DataImport.Source.allCases { for dataType in source.supportedDataTypes { - let e = expectation(description: "button factory called") + let e = expectation(description: "Button item should not be missing") + // build instructions let items = fileImportInstructionsBuilder(source: source, dataType: dataType) { title in + // button title should not be empty XCTAssertFalse(title.isEmpty) + // button should be present in items e.fulfill() return AnyView(EmptyView()) } waitForExpectations(timeout: 0) - if locale == .base { - referenceValues[source, default: [:]][dataType, default: []] = items - } else { - XCTAssertEqual(Set(referenceValues[source]![dataType]!), Set(items), "\(locale).\(source.rawValue).\(dataType.rawValue)") + // find first .string (format) item + let formatItem = items.first + guard case .string(let format) = formatItem else { + XCTFail("First item should be Format String – \(source.rawValue).\(dataType.rawValue)") + continue } + + // find all %-escaped sequences in the format + let formatItemsRegex = /%(?:\d+\$)?(?:lld|ld|d|s|@)/ + let formatArgs = format.matches(of: formatItemsRegex).enumerated().map { (idx, match) in + var value = String(match.output) + if !value.contains("\(idx + 1)") { + // if escape sequence has no index - insert it (convert %d to %4$d) + value.insert(contentsOf: "\(idx + 1)$", at: value.index(after: value.startIndex)) + } + return value + } + + switch locale { + case .base: + // write Base reference value + referenceValues[source, default: [:]][dataType] = formatArgs + default: + // format args should match but their positions may differ + XCTAssertEqual(referenceValues[source]![dataType]!.sorted(), formatArgs.sorted(), + "\(locale).\(source.rawValue).\(dataType.rawValue).formatArgs") + } + + // number of %@ arguments should match number of builder .image and .button arguments + XCTAssertEqual(referenceValues[source]![dataType]!.filter { $0.hasSuffix("@") }.count, + items.filter { if case .image = $0 { true } else if case .view = $0 { true } else { false } }.count, + "\(locale).\(source.rawValue).\(dataType.rawValue).imagesAndButtons") + + // number of %s arguments should match number of builder .string arguments + XCTAssertEqual(referenceValues[source]![dataType]!.filter { $0.hasSuffix("s") }.count, + items[1.../* 0 == format */].filter { if case .string = $0 { true } else { false } }.count, + "\(locale).\(source.rawValue).\(dataType.rawValue).strings") + +#if CI + customAssert = { _, _, _, _ in + XCTFail("\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assert") + } + customAssertionFailure = { _, _, _ in + XCTFail("\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assertionFailure") + } +#endif + _=InstructionsView { + items + } // should not assert } } } @@ -72,7 +124,7 @@ extension InstructionsView.InstructionsItem: Hashable, CustomStringConvertible { public static func == (lhs: DuckDuckGo_Privacy_Browser.InstructionsView.InstructionsItem, rhs: DuckDuckGo_Privacy_Browser.InstructionsView.InstructionsItem) -> Bool { switch lhs { - case .string: if case .string = rhs { return true } + case .string(let value): if case .string(value) = rhs { return true } case .image(let value1): if case .image(let value2) = rhs { return value1.tiffRepresentation! == value2.tiffRepresentation } case .view: if case .view = rhs { return true } } From 2f9d5fa8c70a466c032e0eaf60dcd5749ee74a29 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 13 Dec 2023 19:20:50 +0600 Subject: [PATCH 55/83] fix naming --- UnitTests/DataImport/FileImportViewLocalizationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnitTests/DataImport/FileImportViewLocalizationTests.swift b/UnitTests/DataImport/FileImportViewLocalizationTests.swift index 71ee2ee99c..54603180af 100644 --- a/UnitTests/DataImport/FileImportViewLocalizationTests.swift +++ b/UnitTests/DataImport/FileImportViewLocalizationTests.swift @@ -22,7 +22,7 @@ import SwiftUI @testable import DuckDuckGo_Privacy_Browser @available(macOS 13.0, *) -class YourLocalizationTests: XCTestCase { +class FileImportViewLocalizationTests: XCTestCase { override func tearDown() { Bundle.resetSwizzling() From 1f0c01cd1363802f52fdcd6d594f9e1d22f3e844 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 13 Dec 2023 19:22:14 +0600 Subject: [PATCH 56/83] rm todo --- DuckDuckGo/DataImport/View/FileImportView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index c3bf1fa8e2..06485d8b14 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -678,7 +678,6 @@ struct InstructionsView: View { } assert(usedArgs.subtracting(IndexSet(args.indices)).isEmpty, "Unused arguments at indices \(usedArgs.subtracting(IndexSet(args.indices)))") -// TODO: add tests for each localization and import source combination self.instructions = result } catch { From 7310f711ff6d64e654973ca789a4400e7447aaad Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 17:32:26 +0600 Subject: [PATCH 57/83] added Review/simulated successful zero import result --- .../Model/DataImportViewModel.swift | 110 ++++++------------ .../DataImport/View/DataImportView.swift | 19 ++- 2 files changed, 49 insertions(+), 80 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 346820679a..ed2114cfc7 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -54,7 +54,8 @@ struct DataImportViewModel { private let reportSenderFactory: ReportSenderFactory private func log(_ message: @autoclosure () -> String) { - if OSLog.dataImportExport != .disabled { + if NSApp.runType == .unitTests { + } else if OSLog.dataImportExport != .disabled { os_log(log: .dataImportExport, message()) } else if NSApp.runType == .xcPreviews { print(message()) @@ -110,25 +111,18 @@ struct DataImportViewModel { #if DEBUG || REVIEW - enum ImportError: DataImportError { + // simulated test import failure + struct TestImportError: DataImportError { enum OperationType: Int { case imp } - var type: OperationType { .imp } - var action: DataImportAction { .generic } - var underlyingError: Error? { - if case .err(let err) = self { - return err - } - return nil - } - var errorType: DataImport.ErrorType { .noData } - - case err(Error) + var action: DataImportAction + var underlyingError: Error? { CocoaError(.fileReadUnknown) } + var errorType: DataImport.ErrorType } - var testImportFailureReasons = [DataType: DataImport.ErrorType]() + var testImportResults = [DataType: DataImportResult]() #endif @@ -186,29 +180,19 @@ struct DataImportViewModel { return } - // simulated test import failures #if DEBUG || REVIEW - struct TestImportError: DataImportError { - enum OperationType: Int { - case imp - } - var type: OperationType { .imp } - var action: DataImportAction - var underlyingError: Error? { CocoaError(.fileReadUnknown) } - var errorType: DataImport.ErrorType - } - - guard dataTypes.compactMap({ testImportFailureReasons[$0] }).isEmpty else { - importTask = .detachedWithProgress { [testImportFailureReasons] _ in + // simulated test import failures + guard dataTypes.compactMap({ testImportResults[$0] }).isEmpty else { + importTask = .detachedWithProgress { [testImportResults] _ in var result = DataImportSummary() - let selectedDataTypesWithoutFailureReasons = dataTypes.intersection(importer.importableTypes).subtracting(testImportFailureReasons.keys) + let selectedDataTypesWithoutFailureReasons = dataTypes.intersection(importer.importableTypes).subtracting(testImportResults.keys) var realSummary = DataImportSummary() if !selectedDataTypesWithoutFailureReasons.isEmpty { realSummary = await importer.importData(types: selectedDataTypesWithoutFailureReasons).task.value } for dataType in dataTypes { - if let failureReason = testImportFailureReasons[dataType] { - result[dataType] = .failure(TestImportError(action: .init(dataType), errorType: failureReason)) + if let importResult = testImportResults[dataType] { + result[dataType] = importResult } else { result[dataType] = realSummary[dataType] } @@ -228,24 +212,31 @@ struct DataImportViewModel { log("merging summary \(summary)") + // append successful import results first keeping the original DataType sorting order + self.summary.append(contentsOf: DataType.allCases.compactMap { dataType in + (try? summary[dataType]?.get()).map { + .init(dataType, .success($0)) + } + }) + + // if there‘s read permission/primary password requested - request it and reinitiate import if handleErrors(summary.compactMapValues { $0.error }) { return } var nextScreen: Screen? // merge new import results into the model import summary keeping the original DataType sorting order for (dataType, result) in DataType.allCases.compactMap({ dataType in summary[dataType].map { (dataType, $0) } }) { - self.summary.append( .init(dataType, result) ) - switch result { case .success(let dataTypeSummary): // if a data type can‘t be imported (Yandex/Passwords) - switch to its file import displaying successful import results - if dataTypeSummary.isEmpty, nextScreen == nil { + if dataTypeSummary.isEmpty, !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil { nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys)) } case .failure(let error): - // show "no data to import" screen when no bookmarks|passwords found - if case .noData = error.errorType, screen != .fileImport(dataType: dataType), nextScreen == nil { - nextScreen = .fileImport(dataType: dataType) - } else if !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil { + // successful imports are appended above + self.summary.append( .init(dataType, result) ) + + // show file import screen when import fails or no bookmarks|passwords found + if !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil { // switch to file import of the failed data type displaying successful import results nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys)) } @@ -262,8 +253,12 @@ struct DataImportViewModel { log("mergeImportSummary: feedback") // after last failed datatype show feedback self.screen = .feedback + } else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil { // no next data type manual import screen + let allKeys = self.summary.reduce(into: Set()) { $0.insert($1.dataType) } + log("mergeImportSummary: final summary(\(Set(allKeys)))") + self.screen = .summary(allKeys) } else { - log("mergeImportSummary: summary(\(Set(summary.keys)))") + log("mergeImportSummary: final summary(\(Set(summary.keys)))") self.screen = .summary(Set(summary.keys)) } @@ -711,42 +706,3 @@ extension DataImportViewModel { } } - -extension DataImportViewModel: CustomStringConvertible { - - var description: String { - "DataImportViewModel(importSource: .\(importSource.rawValue), screen: \(screen)\(!summary.isEmpty ? ", summary: \(summary)" : ""))" - } - -} - -extension DataImportViewModel.Screen: CustomStringConvertible { - - var description: String { - switch self { - case .profileAndDataTypesPicker: ".profileAndDataTypesPicker" - case .moreInfo: ".moreInfo" - case .getReadPermission(let url): ".getReadPermission(\(fatalError()))" // TODO: getReadPermission - case .fileImport(dataType: let dataType, summary: let summaryDataTypes): ".fileImport(dataType: .\(dataType)\(!summaryDataTypes.isEmpty ? ", summary: [\(summaryDataTypes.map { "." + $0.rawValue }.joined(separator: ", "))]" : ""))" - case .summary(let dataTypes): ".summary([\(dataTypes.map { "." + $0.rawValue }.joined(separator: ", "))])" - case .feedback: ".feedback" - } - } - -} - -extension DataImportViewModel.DataTypeImportResult: CustomStringConvertible { - - var description: String { - ".init(.\(dataType), \(result))" - } - -} - -extension DataImport.DataTypeSummary: CustomStringConvertible { - - var description: String { - ".init(successful: \(successful), duplicate: \(duplicate), failed: \(failed))" - } - -} diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 270701ef5f..795bbb33aa 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -248,15 +248,28 @@ struct DataImportView: View { } private var noFailure: String { "No failure" } + private var zeroSuccess: String { "Success (0 imported)" } private var allFailureReasons: [String?] { - [noFailure, nil] + DataImport.ErrorType.allCases.map { $0.rawValue } + [noFailure, zeroSuccess, nil] + DataImport.ErrorType.allCases.map { $0.rawValue } } private func failureReasonPicker(for dataType: DataImport.DataType) -> some View { Picker(selection: Binding { - allFailureReasons.firstIndex(of: model.testImportFailureReasons[dataType]?.rawValue ?? noFailure)! + allFailureReasons.firstIndex(where: { failureReason in + model.testImportResults[dataType]?.error?.errorType.rawValue == failureReason + || (failureReason == zeroSuccess && model.testImportResults[dataType] == .success(.empty)) + || (failureReason == noFailure && model.testImportResults[dataType] == nil) + })! } set: { newValue in - model.testImportFailureReasons[dataType] = DataImport.ErrorType(rawValue: allFailureReasons[newValue]!) + let reason = allFailureReasons[newValue]! + switch reason { + case noFailure: model.testImportResults[dataType] = nil + case zeroSuccess: model.testImportResults[dataType] = .success(.empty) + default: + let errorType = DataImport.ErrorType(rawValue: reason)! + let error = DataImportViewModel.TestImportError(action: dataType.importAction, errorType: errorType) + model.testImportResults[dataType] = .failure(error) + } }) { ForEach(allFailureReasons.indices, id: \.self) { idx in if let failureReason = allFailureReasons[idx] { From df8b63b00abecbc2405a2b7a018f966c3a015e04 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 17:33:06 +0600 Subject: [PATCH 58/83] fix pbxproj --- DuckDuckGo.xcodeproj/project.pbxproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index b4b3ad225d..f5ebbddfeb 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2714,6 +2714,7 @@ B662D3DF275616FF0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */; }; B6656E0D2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */; }; B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */; }; + B6656E5B2B2ADB1C008798A1 /* RequestFilePermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */; }; B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; B6676BE22AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; B6685E3D29A602D90043D2EE /* ExternalAppSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B687B7CB2947A1E9001DEA6F /* ExternalAppSchemeHandler.swift */; }; @@ -2850,7 +2851,6 @@ B6B5F5822B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; B6B5F5842B03580A008DB58A /* RequestFilePermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */; }; B6B5F5852B03580A008DB58A /* RequestFilePermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */; }; - B6B5F5872B03580A008DB58A /* RequestFilePermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */; }; B6B5F5892B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */; }; B6B5F58A2B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */; }; B6B5F58C2B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */; }; @@ -10541,6 +10541,7 @@ 4B957A5A2AC7AE700062CA31 /* CSVParser.swift in Sources */, 4B957A5B2AC7AE700062CA31 /* PixelDataModel.xcdatamodeld in Sources */, 4B957A5C2AC7AE700062CA31 /* PrivacyDashboardWebView.swift in Sources */, + B6656E5B2B2ADB1C008798A1 /* RequestFilePermissionView.swift in Sources */, 4B957A5D2AC7AE700062CA31 /* AppearancePreferences.swift in Sources */, 4B957A5E2AC7AE700062CA31 /* DownloadListCoordinator.swift in Sources */, 4B957A5F2AC7AE700062CA31 /* AdClickAttributionTabExtension.swift in Sources */, From fb474be2e368b4e266dea4eabd0798ae1383a1bf Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 17:36:17 +0600 Subject: [PATCH 59/83] add throttled cancellable progress tracking to data importers/data import view --- .../Common/Extensions/TaskWithProgress.swift | 4 +- DuckDuckGo/Common/Localizables/UserText.swift | 4 +- DuckDuckGo/DataImport/DataImport.swift | 4 +- .../DataImport/Logins/CSV/CSVImporter.swift | 32 ++++++++++--- .../DataImport/Logins/CSV/CSVParser.swift | 1 + .../Chromium/ChromiumDataImporter.swift | 40 ++++++++++++++--- .../Logins/Firefox/FirefoxDataImporter.swift | 45 ++++++++++++++----- .../DataImport/Logins/LoginImport.swift | 2 +- .../SecureVaultLoginImporter.swift | 6 ++- .../DataImport/View/DataImportView.swift | 25 ++++++++--- UnitTests/DataImport/DataImportMocks.swift | 3 +- 11 files changed, 126 insertions(+), 40 deletions(-) diff --git a/DuckDuckGo/Common/Extensions/TaskWithProgress.swift b/DuckDuckGo/Common/Extensions/TaskWithProgress.swift index ba1c3b054a..145a1d5a33 100644 --- a/DuckDuckGo/Common/Extensions/TaskWithProgress.swift +++ b/DuckDuckGo/Common/Extensions/TaskWithProgress.swift @@ -111,7 +111,7 @@ extension TaskWithProgress: AnyTask {} extension AnyTask where Failure == Error { - static func detachedWithProgress(_ progress: ProgressUpdate? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (TaskProgressUpdateCallback) async throws -> Success) -> TaskWithProgress { + static func detachedWithProgress(_ progress: ProgressUpdate? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async throws -> Success) -> TaskWithProgress { let (progressStream, progressContinuation) = TaskProgress.makeStream() if let progress { progressContinuation.yield(.progress(progress)) @@ -144,7 +144,7 @@ extension AnyTask where Failure == Error { extension AnyTask where Failure == Never { - static func detachedWithProgress(_ progress: ProgressUpdate? = nil, completed: UInt? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (TaskProgressUpdateCallback) async -> Success) -> TaskWithProgress { + static func detachedWithProgress(_ progress: ProgressUpdate? = nil, completed: UInt? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async -> Success) -> TaskWithProgress { let (progressStream, progressContinuation) = TaskProgress.makeStream() if let progress { progressContinuation.yield(.progress(progress)) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index c3eb54a023..93db84e23c 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -640,7 +640,7 @@ struct UserText { static func importingBookmarks(_ numberOfBookmarks: Int?) -> String { if let numberOfBookmarks, numberOfBookmarks > 0 { - let localized = NSLocalizedString("import.bookmarks.number.progress.text", value: "Importing %d bookmarks", comment: "Operation progress info message about %d number of bookmarks being imported") + let localized = NSLocalizedString("import.bookmarks.number.progress.text", value: "Importing %d bookmarks…", comment: "Operation progress info message about %d number of bookmarks being imported") return String(format: localized, numberOfBookmarks) } else { return NSLocalizedString("import.bookmarks.indefinite.progress.text", value: "Importing bookmarks…", comment: "Operation progress info message about indefinite number of bookmarks being imported") @@ -649,7 +649,7 @@ struct UserText { static func importingPasswords(_ numberOfPasswords: Int?) -> String { if let numberOfPasswords, numberOfPasswords > 0 { - let localized = NSLocalizedString("import.passwords.number.progress.text", value: "Importing %d passwords", comment: "Operation progress info message about %d number of passwords being imported") + let localized = NSLocalizedString("import.passwords.number.progress.text", value: "Importing %d passwords…", comment: "Operation progress info message about %d number of passwords being imported") return String(format: localized, numberOfPasswords) } else { return NSLocalizedString("import.passwords.indefinite.progress.text", value: "Importing passwords…", comment: "Operation progress info message about indefinite number of passwords being imported") diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 1b99c5de52..3d8b47185b 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -490,10 +490,10 @@ enum DataImportResult: CustomStringConvertible { /// - Parameter transform: A closure that takes the success value of the instance. /// - Returns: A `Result` instance, either from the closure or the previous /// `.failure`. - @inlinable public func flatMap(_ transform: (T) -> DataImportResult) -> DataImportResult { + @inlinable public func flatMap(_ transform: (T) throws -> DataImportResult) rethrows -> DataImportResult { switch self { case .success(let value): - switch transform(value) { + switch try transform(value) { case .success(let transformedValue): return .success(transformedValue) case .failure(let error): diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index e74c3e4924..b99d50b193 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -203,13 +203,22 @@ final class CSVImporter: DataImporter { } func importData(types: Set) -> DataImportTask { - .detachedWithProgress { _ in - let result = self.importLoginsSync() - return [.passwords: result] + .detachedWithProgress { updateProgress in + do { + let result = try await self.importLoginsSync(updateProgress: updateProgress) + return [.passwords: result] + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] } } - func importLoginsSync() -> DataImportResult { + private func importLoginsSync(updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportResult { + + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) + let fileContents: String do { fileContents = try String(contentsOf: fileURL, encoding: .utf8) @@ -218,13 +227,24 @@ final class CSVImporter: DataImporter { } do { + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.2)) + let loginCredentials = try Self.extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? { + try Task.checkCancellation() throw LoginImporterError(error: nil, type: .malformedCSV) }() - let summary = try loginImporter.importLogins(loginCredentials) - return .success(summary) + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 0.5)) + let summary = try loginImporter.importLogins(loginCredentials) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, fraction: 0.5 + 0.5 * (Double(count) / Double(loginCredentials.count)))) + } + + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 1.0)) + + return .success(summary) + } catch is CancellationError { + throw CancellationError() } catch let error as DataImportError { return .failure(error) } catch { diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift index de99525733..5f2785a2d2 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift @@ -28,6 +28,7 @@ struct CSVParser { var parser = Parser() for character in string { + try Task.checkCancellation() try parser.accept(character) } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift index d3e28bb973..9c6d15e93a 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift @@ -49,32 +49,59 @@ internal class ChromiumDataImporter: DataImporter { /// Start import process. Can throw synchronously if pre-import checks fail (e.g. file access) func importData(types: Set) -> DataImportTask { .detachedWithProgress { updateProgress in - await self.importData(types: types, updateProgress: updateProgress) + do { + let result = try await self.importDataSync(types: types, updateProgress: updateProgress) + return result + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] } } - private func importData(types: Set, updateProgress: DataImportProgressCallback) async -> DataImportSummary { + private func importDataSync(types: Set, updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportSummary { var summary = DataImportSummary() + let dataTypeFraction = 1.0 / Double(types.count) + if types.contains(.passwords), let loginImporter { + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) + let loginReader = ChromiumLoginReader(chromiumDataDirectoryURL: profile.profileURL, source: source) let loginResult = loginReader.readLogins(modalWindow: nil) - let loginsSummary = loginResult.flatMap { logins in + let loginsSummary = try loginResult.flatMap { logins in do { - return try .success(loginImporter.importLogins(logins)) + return try .success(loginImporter.importLogins(logins) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, + fraction: dataTypeFraction * (0.5 + Double(count) / Double(logins.count)))) + }) + } catch is CancellationError { + throw CancellationError() } catch { return .failure(LoginImporterError(error: error)) } } summary[.passwords] = loginsSummary + + try updateProgress(.importingPasswords(numberOfPasswords: try? loginResult.get().count, fraction: dataTypeFraction * 1.0)) } - if types.contains(.bookmarks) { + let passwordsFraction: Double = types.contains(.passwords) ? 0.5 : 0.0 + if types.contains(.bookmarks) + // don‘t proceed with bookmarks import on Keychain prompt denial + && (summary[.passwords]?.error as? ChromiumLoginReader.ImportError)?.type != .userDeniedKeychainPrompt { + + try updateProgress(.importingBookmarks(numberOfBookmarks: nil, fraction: passwordsFraction + 0.0)) + let bookmarkReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: profile.profileURL) let bookmarkResult = bookmarkReader.readBookmarks() + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 0.5)) + let bookmarksSummary = bookmarkResult.map { bookmarks in bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(source)) } @@ -84,6 +111,9 @@ internal class ChromiumDataImporter: DataImporter { } summary[.bookmarks] = bookmarksSummary.map { .init($0) } + + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 1.0)) } return summary diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift index d7efae1b1e..4e994893db 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift @@ -45,23 +45,38 @@ internal class FirefoxDataImporter: DataImporter { func importData(types: Set) -> DataImportTask { .detachedWithProgress { updateProgress in - let result = await self.importDataSync(types: types, updateProgress: updateProgress) - return result + do { + let result = try await self.importDataSync(types: types, updateProgress: updateProgress) + return result + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] } } - private func importDataSync(types: Set, updateProgress: DataImportProgressCallback) async -> DataImportSummary { + private func importDataSync(types: Set, updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportSummary { var summary = DataImportSummary() + let dataTypeFraction = 1.0 / Double(types.count) + if types.contains(.passwords) { - try? updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) let loginReader = FirefoxLoginReader(firefoxProfileURL: profile.profileURL, primaryPassword: self.primaryPassword) let loginResult = loginReader.readLogins(dataFormat: nil) - let loginsSummary = loginResult.flatMap { logins in + try updateProgress(.importingPasswords(numberOfPasswords: try? loginResult.get().count, fraction: dataTypeFraction * 0.5)) + + let loginsSummary = try loginResult.flatMap { logins in do { - return try .success(loginImporter.importLogins(logins)) + return try .success(loginImporter.importLogins(logins) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, + fraction: dataTypeFraction * (0.5 + 0.5 * Double(count) / Double(logins.count)))) + }) + } catch is CancellationError { + throw CancellationError() } catch { return .failure(LoginImporterError(error: error)) } @@ -69,15 +84,22 @@ internal class FirefoxDataImporter: DataImporter { summary[.passwords] = loginsSummary - try? updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 1.0)) + try updateProgress(.importingPasswords(numberOfPasswords: try? loginResult.get().count, fraction: dataTypeFraction * 1.0)) } - if types.contains(.bookmarks) { - try? updateProgress(.importingBookmarks(numberOfBookmarks: nil, fraction: 0.0)) + let passwordsFraction: Double = types.contains(.passwords) ? 0.5 : 0.0 + if types.contains(.bookmarks) + // don‘t proceed with bookmarks import on invalid Primary Password + && (summary[.passwords]?.error as? FirefoxLoginReader.ImportError)?.type != .requiresPrimaryPassword { + + try updateProgress(.importingBookmarks(numberOfBookmarks: nil, fraction: passwordsFraction + 0.0)) let bookmarkReader = FirefoxBookmarksReader(firefoxDataDirectoryURL: profile.profileURL) let bookmarkResult = bookmarkReader.readBookmarks() + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 0.5)) + let bookmarksSummary = bookmarkResult.map { bookmarks in bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(source)) } @@ -88,9 +110,10 @@ internal class FirefoxDataImporter: DataImporter { summary[.bookmarks] = bookmarksSummary.map { .init($0) } - try? updateProgress(.importingBookmarks(numberOfBookmarks: nil, fraction: 1.0)) + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 1.0)) } - try? updateProgress(.done) + try updateProgress(.done) return summary } diff --git a/DuckDuckGo/DataImport/Logins/LoginImport.swift b/DuckDuckGo/DataImport/Logins/LoginImport.swift index 96e3304295..ecd8a095f9 100644 --- a/DuckDuckGo/DataImport/Logins/LoginImport.swift +++ b/DuckDuckGo/DataImport/Logins/LoginImport.swift @@ -39,6 +39,6 @@ struct ImportedLoginCredential: Equatable { protocol LoginImporter { - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.DataTypeSummary + func importLogins(_ logins: [ImportedLoginCredential], progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary } diff --git a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift index e8e1267868..17acb874e0 100644 --- a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift +++ b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift @@ -22,7 +22,7 @@ import SecureStorage final class SecureVaultLoginImporter: LoginImporter { - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.DataTypeSummary { + func importLogins(_ logins: [ImportedLoginCredential], progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { let vault = try AutofillSecureVaultFactory.makeVault(errorReporter: SecureVaultErrorReporter.shared) var successful: [String] = [] @@ -33,7 +33,7 @@ final class SecureVaultLoginImporter: LoginImporter { let hashingSalt = try vault.getHashingSalt() try vault.inDatabaseTransaction { database in - for login in logins { + for (idx, login) in logins.enumerated() { let title = login.title let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url, notes: login.notes) let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: login.password.data(using: .utf8)!) @@ -55,6 +55,8 @@ final class SecureVaultLoginImporter: LoginImporter { failed.append(importSummaryValue) } } + + try progressCallback(idx + 1) } } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 795bbb33aa..f83b844038 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -46,8 +46,12 @@ struct DataImportView: View { @State var model = DataImportViewModel() - @State private var progressText: String? - @State private var progressFraction: Double? + struct ProgressState { + let text: String? + let fraction: Double? + let updated: CFTimeInterval + } + @State private var progress: ProgressState? #if DEBUG || REVIEW @State private var debugViewDisabled: Bool = false @@ -68,6 +72,9 @@ struct DataImportView: View { // if import in progress… if let importProgress = model.importProgress { progressView(importProgress) + .padding(.leading, 20) + .padding(.trailing, 20) + .padding(.bottom, 8) } Divider() @@ -173,10 +180,9 @@ struct DataImportView: View { private func progressView(_ progress: TaskProgress) -> some View { // Progress bar with label: Importing [bookmarks|passwords]… - ProgressView(value: progressFraction) { - Text(progressText ?? "") + ProgressView(value: self.progress?.fraction) { + Text(self.progress?.text ?? "") } - .padding(.top, 24) .task { // when model.importProgress async sequence not nil // receive progress updates events and update model on completion @@ -209,8 +215,13 @@ struct DataImportView: View { for await event in progress { switch event { case .progress(let progress): - progressText = progress.description - progressFraction = progress.fraction + let currentTime = CACurrentMediaTime() + // throttle progress updates + if (self.progress?.updated ?? 0) < currentTime - 0.2 { + self.progress = .init(text: progress.description, + fraction: progress.fraction, + updated: currentTime) + } // update view model on completion case .completed(.success(let newModel)): diff --git a/UnitTests/DataImport/DataImportMocks.swift b/UnitTests/DataImport/DataImportMocks.swift index 5bea21f230..7c83279142 100644 --- a/UnitTests/DataImport/DataImportMocks.swift +++ b/UnitTests/DataImport/DataImportMocks.swift @@ -20,10 +20,9 @@ import Foundation @testable import DuckDuckGo_Privacy_Browser final class MockLoginImporter: LoginImporter { - var importedLogins: DataImportSummary? - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.DataTypeSummary { +func importLogins(_ logins: [DuckDuckGo_Privacy_Browser.ImportedLoginCredential], progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { let summary = DataImport.DataTypeSummary(successful: logins.count, duplicate: 0, failed: 0) self.importedLogins = [.passwords: .success(summary)] From 7742cd884a423be2613b611e2438a388a2e7143d Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 17:37:10 +0600 Subject: [PATCH 60/83] fix doubling import when keychain password/primary password rejected --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index ed2114cfc7..bd0da1a6b4 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -169,7 +169,10 @@ struct DataImportViewModel { // are we handling file import or browser selected data types import? let dataType: DataType? = self.screen.fileImportDataType - let dataTypes = dataType.map { [$0] } ?? selectedDataTypes + // either import only data type for file import + let dataTypes = dataType.map { [$0] } + // or all the selected data types subtracting the ones that are already imported + ?? selectedDataTypes.subtracting(self.summary.filter { $0.result.isSuccess }.map(\.dataType)) let importer = dataImporterFactory(importSource, dataType, url, primaryPassword) log("import \(dataTypes) at \"\(url.path)\" using \(type(of: importer))") From 64ba06579f72d5caa37209c355227bdd91dbb00f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 17:48:55 +0600 Subject: [PATCH 61/83] tests --- DuckDuckGo/DataImport/DataImport.swift | 13 +- .../Feedback/Model/FeedbackSender.swift | 4 +- DuckDuckGo/Localizable.xcstrings | 308 ++- .../DataImport/DataImportViewModelTests.swift | 2068 +++++++++++++---- 4 files changed, 1865 insertions(+), 528 deletions(-) diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 3d8b47185b..5d56c60b02 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -145,6 +145,13 @@ enum DataImport { var description: String { rawValue } + var importAction: DataImportAction { + switch self { + case .bookmarks: .bookmarks + case .passwords: .passwords + } + } + } struct DataTypeSummary: Equatable { @@ -153,7 +160,11 @@ enum DataImport { let failed: Int var isEmpty: Bool { - successful == 0 && duplicate == 0 && failed == 0 + self == .empty + } + + static var empty: Self { + DataTypeSummary(successful: 0, duplicate: 0, failed: 0) } init(successful: Int, duplicate: Int, failed: Int) { diff --git a/DuckDuckGo/Feedback/Model/FeedbackSender.swift b/DuckDuckGo/Feedback/Model/FeedbackSender.swift index df78c53a01..3e032d36aa 100644 --- a/DuckDuckGo/Feedback/Model/FeedbackSender.swift +++ b/DuckDuckGo/Feedback/Model/FeedbackSender.swift @@ -58,8 +58,8 @@ final class FeedbackSender { Import source: \(report.importSourceDescription) Error: \(report.error.localizedDescription) """, - appVersion: "\(AppVersion.shared.versionNumber)", - osVersion: "\(ProcessInfo.processInfo.operatingSystemVersion)")) + appVersion: report.appVersion, + osVersion: report.osVersion)) } } diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 6926555010..ee85568834 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -559,7 +559,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Also lock access to Login and Credit Card form fill." + "value" : "Also lock password form fill" } } } @@ -655,7 +655,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Anyone with access to your device will be able to use and modify your Autofill data if not locked." + "value" : "If not locked, anyone with access to your device will be able to use and modify your autofill data. For security purposes, credit card form fill always requires authentication." } } } @@ -4947,13 +4947,13 @@ } }, "network.protection.navbar.status.view.share.feedback" : { - "comment" : "Menu item for 'Share Feedback' in the Network Protection status view that's shown in the navigation bar", + "comment" : "Menu item for 'Send Feedback' in the Network Protection status view that's shown in the navigation bar", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Share Feedback…" + "value" : "Send Feedback…" } } } @@ -5117,42 +5117,6 @@ } } }, - "newTab.setup.cookie.manager.action" : { - "comment" : "Action title on the action menu of the Cookie Manager card of the Set Up section in the home page", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Handle Pop-ups For Me" - } - } - } - }, - "newTab.setup.cookie.manager.summary" : { - "comment" : "Summary of the Cookie Manager card of the Set Up section in the home page", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "We need your permission to say no to cookies on your behalf. Easy choice." - } - } - } - }, - "newTab.setup.cookie.manager.title" : { - "comment" : "Title of the Cookie Manager card of the Set Up section in the home page", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Let Us Handle Cookie Pop-ups" - } - } - } - }, "newTab.setup.default.browser.action" : { "comment" : "Action title on the action menu of the Default Browser card", "extractionState" : "extracted_with_value", @@ -8637,6 +8601,270 @@ } } }, + "vpn.feedback-form.button.cancel" : { + "comment" : "Title for the Cancel button of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "vpn.feedback-form.button.done" : { + "comment" : "Title for the Done button of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "vpn.feedback-form.button.submit" : { + "comment" : "Title for the Submit button of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit" + } + } + } + }, + "vpn.feedback-form.button.submitting" : { + "comment" : "Title for the Submitting state of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submitting…" + } + } + } + }, + "vpn.feedback-form.category.browser-crash-or-freeze" : { + "comment" : "Title for the browser crash/freeze category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN causes browser to crash or freeze" + } + } + } + }, + "vpn.feedback-form.category.fails-to-connect" : { + "comment" : "Title for the 'VPN fails to connect' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN fails to connect" + } + } + } + }, + "vpn.feedback-form.category.feature-request" : { + "comment" : "Title for the 'VPN feature request' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN feature request" + } + } + } + }, + "vpn.feedback-form.category.issues-with-apps" : { + "comment" : "Title for the category 'VPN causes issues with other apps or websites' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN causes issues with other apps or websites" + } + } + } + }, + "vpn.feedback-form.category.local-device-connectivity" : { + "comment" : "Title for the local device connectivity category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN won't let me connect to local device" + } + } + } + }, + "vpn.feedback-form.category.other" : { + "comment" : "Title for the 'other VPN feedback' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Other VPN feedback" + } + } + } + }, + "vpn.feedback-form.category.select-category" : { + "comment" : "Title for the category selection state of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select a category" + } + } + } + }, + "vpn.feedback-form.category.too-slow" : { + "comment" : "Title for the 'VPN is too slow' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN connection is too slow" + } + } + } + }, + "vpn.feedback-form.category.unable-to-install" : { + "comment" : "Title for the 'unable to install' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unable to install VPN" + } + } + } + }, + "vpn.feedback-form.sending-confirmation.description" : { + "comment" : "Title for the feedback sent view description of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your feedback will help us improve the\nDuckDuckGo VPN." + } + } + } + }, + "vpn.feedback-form.sending-confirmation.error" : { + "comment" : "Title for the feedback sending error text of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We couldn't send your feedback right now, please try again." + } + } + } + }, + "vpn.feedback-form.sending-confirmation.title" : { + "comment" : "Title for the feedback sent view title of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Thank you!" + } + } + } + }, + "vpn.feedback-form.text-1" : { + "comment" : "Text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please describe what's happening, what you expected to happen, and the steps that led to the issue:" + } + } + } + }, + "vpn.feedback-form.text-2" : { + "comment" : "Text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "In addition to the details entered into this form, your app issue report will contain:" + } + } + } + }, + "vpn.feedback-form.text-3" : { + "comment" : "Bullet text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "• Whether specific DuckDuckGo features are enabled" + } + } + } + }, + "vpn.feedback-form.text-4" : { + "comment" : "Bullet text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "• Aggregate DuckDuckGo app diagnostics" + } + } + } + }, + "vpn.feedback-form.text-5" : { + "comment" : "Text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features." + } + } + } + }, + "vpn.feedback-form.title" : { + "comment" : "Title for each screen of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Help Improve the DuckDuckGo VPN" + } + } + } + }, "vpn.general.title" : { "comment" : "General section title in VPN settings", "extractionState" : "extracted_with_value", diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift index b009f3f7a3..9871956a7e 100644 --- a/UnitTests/DataImport/DataImportViewModelTests.swift +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -21,505 +21,1505 @@ import Foundation import XCTest @testable import DuckDuckGo_Privacy_Browser -@MainActor -final class DataImportViewModelTests: XCTestCase { +// swiftlint:disable:next type_body_length +@MainActor final class DataImportViewModelTests: XCTestCase { typealias Source = DataImport.Source typealias BrowserProfileList = DataImport.BrowserProfileList typealias BrowserProfile = DataImport.BrowserProfile typealias DataType = DataImport.DataType + typealias DataTypeSummary = DataImport.DataTypeSummary var model: DataImportViewModel! - override func setUp() { + override func tearDown() { model = nil -// importTask = nil + importTask = nil + openPanelCallback = nil + } + + // MARK: - Tests + + // Validate supported DataType-s of all Import Sources + func testDataImportSourceSupportedDataTypes() { + for source in Source.allCases { + if source.initialScreen == .profileAndDataTypesPicker { + if source == .tor { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) + } else { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks, .passwords], source.importSourceName) + } + } else { + if source == .bookmarksHTML { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) + } else { + XCTAssertEqual(source.supportedDataTypes, [.passwords], source.importSourceName) + } + } + } + } - // TODO: remove me - OSLog.loggingCategories.insert(OSLog.AppCategories.dataImportExport.rawValue) + func testWhenPreferredImportSourcesAvailable_firstPreferredSourceIsSelected() { + model = DataImportViewModel(availableImportSources: [.safari, .csv, .bitwarden], preferredImportSources: [.firefox, .chrome, .bitwarden, .safari]) + XCTAssertEqual(model.importSource, .bitwarden) } - func setupModel(with source: Source, profiles: [(ThirdPartyBrowser) -> BrowserProfile], screen: DataImportViewModel.Screen? = nil, summary: [DataImportViewModel.DataTypeImportResult] = []) { - model = DataImportViewModel(importSource: source, screen: screen, summary: summary, loadProfiles: { browser in - .init(browser: browser, profiles: profiles.map { $0(browser) }) { profile in - { - // TODO: unavailability; test invalid profiles - .init(logins: .available, bookmarks: .available) + func testWhenModelIsInstantiated_initialScreenIsShown() { + for source in Source.allCases { + model = DataImportViewModel(importSource: source) + XCTAssertEqual(model.screen, source.initialScreen, "\(source)") + } + } + + func testImportTaskCancellation() async throws { + setupModel(with: .firefox, profiles: [BrowserProfile.test]) + + let e1 = expectation(description: "task started") + let e2 = expectation(description: "task cancelled") + self.importTask = { _, progress in + e1.fulfill() + await Task.yield() // let cancellation in + + do { + try? await Task.sleep(interval: 10) // forever + try progress(.importingPasswords(numberOfPasswords: nil, fraction: 0)) + } catch is CancellationError { + e2.fulfill() + } catch { + XCTFail("unexpected \(error)") + } + return [:] + } + let eDismissed = expectation(description: "dismissed") + Task { @MainActor in + await fulfillment(of: [e1], timeout: 1) + var model = self.model! + model.performAction(for: .cancel) { + eDismissed.fulfill() + } + } + + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox)) + await fulfillment(of: [e2, eDismissed], timeout: 0) + } + + // MARK: - Browser profiles + + func testWhenNoProfilesAreLoaded_selectedProfileIsNil() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { source in + XCTAssertEqual(source, .safari) + return .init(browser: source, profiles: []) + }) + XCTAssertNil(model.selectedProfile) + } + + func testWhenProfilesAreLoaded_defaultProfileIsSelected() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { source in + XCTAssertEqual(source, .firefox) + return .init(browser: source, profiles: [.test(for: source), .default(for: source)]) + }) + XCTAssertEqual(model.selectedProfile, .default(for: .firefox)) + } + + func testWhenInvalidProfilesArePresent_onlyValidProfilesShown() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { continue } + + model = DataImportViewModel(importSource: source, loadProfiles: { browser in + .init(browser: browser, profiles: [ + .default(for: browser), + .test(for: browser), + .test2(for: browser), + .test3(for: browser), + ]) { profile in + { // swiftlint:disable:this opening_brace + switch profile { + case .default(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + case .test(for: browser): .init(logins: .available, bookmarks: .unavailable(path: "test")) + case .test2(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .available) + default: .init(logins: .available, bookmarks: .available) + } + } + } + }) + + XCTAssertEqual(model.browserProfiles?.validImportableProfiles, [ + .test(for: browser), + .test2(for: browser), + .test3(for: browser), + ]) + } + } + + func testWhenImportSourceChanged_AnotherDefaultProfileIsSelected() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .default(for: $0) ]) }) + model.update(with: .chromium) + XCTAssertEqual(model.selectedProfile, .default(for: .chrome)) + } + + func testWhenNoDefaultProfileIsLoaded_firstProfileIsSelected() { + model = DataImportViewModel(importSource: .chrome, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .test2(for: $0) ]) }) + XCTAssertEqual(model.selectedProfile, .test(for: .chrome)) + } + + // MARK: - Buttons + + func testWhenNextButtonIsClicked_screenForTheButtonIsShown() { + setupModel(with: .safari) + model.performAction(.next(.fileImport(dataType: .bookmarks))) + XCTAssertEqual(model.screen, .fileImport(dataType: .bookmarks)) + } + + func testWhenNoDataTypesSelected_actionButtonDisabled() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + model = DataImportViewModel(importSource: source) + + XCTAssertEqual(model.selectedDataTypes, source.supportedDataTypes) + for dataType in source.supportedDataTypes { + model.setDataType(dataType, selected: false) + } + XCTAssertEqual(model.selectedDataTypes, []) + + XCTAssertEqual(model.buttons, [.cancel, .initiateImport(disabled: true)]) + } + } + + func testWhenCancelButtonClicked_dismissIsCalled() { + model = DataImportViewModel(importSource: .safari) + + XCTAssertEqual(model.secondaryButton, .cancel) + let e = expectation(description: "dismiss called") + model.performAction(for: .cancel) { + e.fulfill() + } + + waitForExpectations(timeout: 0) + } + + func testWhenProfilesAreLoadedAndImporterCanImportStraightAway_buttonActionsAreCancelAndImport() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock() + }) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false)) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPassword_buttonActionsAreCancelAndMoreInfo() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock(keychainPasswordRequiredFor: [.passwords]) + }) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .next(.moreInfo)) + XCTAssertEqual(model.secondaryButton, .cancel) + + model.performAction(.next(.moreInfo)) + + XCTAssertEqual(model.screen, .moreInfo) + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false)) + XCTAssertEqual(model.secondaryButton, .back) + + model.performAction(.back) + XCTAssertEqual(model.screen, Source.safari.initialScreen) + } + + func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPasswordButPasswordsDataTypeNotSelected_buttonActionsAreCancelAndImport() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock(keychainPasswordRequiredFor: [.passwords]) + }) + model.setDataType(.passwords, selected: false) + + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false)) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenFileImportSourceSelected_buttonActionsAreCancelAndNone() { + for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil || source.isBrowser == false { + model = DataImportViewModel(importSource: source, loadProfiles: { + XCTAssertNotNil(ThirdPartyBrowser.browser(for: source), "Unexpected loadProfiles – \(source)") + return .init(browser: $0, profiles: [.test(for: $0)]) + }) + + XCTAssertEqual(model.screen, .fileImport(dataType: source.supportedDataTypes.first!, summary: []), "\(source)") + XCTAssertEqual(model.selectedDataTypes, source.supportedDataTypes, "\(source)") + XCTAssertNil(model.actionButton) + XCTAssertEqual(model.secondaryButton, .cancel, "\(source)") + } + } + + func testWhenNoProfilesAreLoaded_buttonActionsAreCancelAndProceedToFileImport() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .bookmarks))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenNoProfilesAreLoadedAndBookmarksDataTypeUnselected_fileImportDataTypeChanges() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) + + model.setDataType(.bookmarks, selected: false) + + XCTAssertEqual(model.selectedDataTypes, [.passwords]) + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenPasswordsDataTypeUnselected_fileImportDataTypeChanges() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) + + model.setDataType(.passwords, selected: false) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks]) + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .bookmarks))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenImportSourceChanges_selectedDataTypesAreReset() { + model = DataImportViewModel(importSource: .safari) + + model.setDataType(.bookmarks, selected: false) + model.setDataType(.passwords, selected: false) + + model.update(with: .brave) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .next(.moreInfo)) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenImporterCannotImportPasswords_nextScreenIsFileImport() { + model = DataImportViewModel(importSource: .yandex, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock(importableTypes: [.bookmarks]) + }) + + model.setDataType(.bookmarks, selected: false) + + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenReadPermissionRequired_nextScreenIsReadPermission() { + setupModel(with: .safari, profiles: [BrowserProfile.test]) { _, _, _, _ in + ImporterMock { _, _ in + [.passwords: SafariBookmarksReader.ImportError(type: .readPlist, underlyingError: + CocoaError(.fileReadNoPermission, userInfo: [kCFErrorURLKey as String: URL.testCSV]))] + } + } + + XCTAssertEqual(model.buttons, [.cancel, .initiateImport(disabled: false)]) + model.performAction(.initiateImport(disabled: false)) + + let expectation = DataImportViewModel(importSource: .safari, screen: .getReadPermission(.testCSV)) + XCTAssertEqual(model.description, expectation.description) + } + + // MARK: - Import from browser profile + // MARK: Primary Password + + func testWhenImporterRequiresPrimaryPassword_passwordIsRequested() async throws { + var e: XCTestExpectation! + setupModel(with: .firefox, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, accessValidator: { importer, dataTypes in + XCTAssertEqual(dataTypes, [.bookmarks, .passwords]) + if let password = importer.password { + XCTAssertEqual(password, p) + XCTAssertEqual(password, "password") + return [:] + } else { + return [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] + } + }, importTask: self.importTask) + } requestPrimaryPasswordCallback: { source in + XCTAssertEqual(source, .firefox) + e.fulfill() + return "password" + } + + e = expectation(description: "should request password") + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox), resultingWith: { + return [ + .bookmarks: .success(.init(successful: 1, duplicate: 1, failed: 1)), + .passwords: .success(.init(successful: 2, duplicate: 2, failed: 2)), + ] + }()) + await fulfillment(of: [e], timeout: 0) + + let expectation = DataImportViewModel(importSource: .firefox, screen: .summary([.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 1, duplicate: 1, failed: 1))), .init(.passwords, .success(.init(successful: 2, duplicate: 2, failed: 2)))]) + XCTAssertEqual(model.description, expectation.description) + + } + + func testWhenImporterRequiresPrimaryPasswordAndPasswordIsInvalid_passwordIsRequestedAgain() async throws { + var e: XCTestExpectation! + var e2: XCTestExpectation! + setupModel(with: .firefox, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, accessValidator: { importer, _ in + if let password = importer.password { + return [:] + } else { + return [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] } + }, importTask: self.importTask) + } requestPrimaryPasswordCallback: { source in + XCTAssertEqual(source, .firefox) + if let e2 { + e2.fulfill() + return "password" + } else { + e.fulfill() + return "invalid_password" + } + } + + e = expectation(description: "should request password") + self.importTask = { dataTypes, _ in + if e2 == nil { + XCTAssertEqual(dataTypes, [.bookmarks, .passwords], "first data import should contain both data types") + e2 = self.expectation(description: "should request password again") + return [ + .bookmarks: .success(.init(successful: 1, duplicate: 1, failed: 1)), + .passwords: .failure(FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)), + ] + } else { + XCTAssertEqual(dataTypes, [.passwords], "second data import should contain only failed data type (passwords)") + return [ + .passwords: .success(.init(successful: 2, duplicate: 2, failed: 2)), + ] } - }, dataImporterFactory: dataImporter) + } + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox)) + await fulfillment(of: [e, e2], timeout: 0) + + let expected = DataImportViewModel(importSource: .firefox, screen: .summary([.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 1, duplicate: 1, failed: 1))), .init(.passwords, .success(.init(successful: 2, duplicate: 2, failed: 2)))]) + XCTAssertEqual(model.description, expected.description) } - func selectProfile(_ profile: BrowserProfile, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { - XCTAssertTrue(model.browserProfiles?.validImportableProfiles.contains(profile) == true, message().with("profile"), file: file, line: line) - model.selectedProfile = profile + func testWhenImporterRequiresPrimaryPasswordButRejected_initialStateRestored() async throws { + let e = expectation(description: "should request password") + setupModel(with: .firefox, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, accessValidator: { _, _ in + [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] + }, importTask: self.importTask) + } requestPrimaryPasswordCallback: { _ in + e.fulfill() + return nil + } + + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox)) + await fulfillment(of: [e], timeout: 0) + + let expected = DataImportViewModel(importSource: .firefox, screen: Source.firefox.initialScreen) + XCTAssertEqual(model.description, expected.description) + } + + func testWhenImporterRequiresKeychainPasswordButRejected_moreInfoScreenRestored() async throws { + setupModel(with: .brave, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, keychainPasswordRequiredFor: [.passwords], accessValidator: { _, _ in + [.passwords: ChromiumLoginReader.ImportError(type: .userDeniedKeychainPrompt)] + }, importTask: self.importTask) + } + + model.performAction(.next(.moreInfo)) + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.brave)) + + let expected = DataImportViewModel(importSource: .brave, screen: .moreInfo) + XCTAssertEqual(model.description, expected.description) + } + + // MARK: Browser Sources: initial -> import -> bookmarks success… + + // initial -> import -> bookmarks success, passwords success -> summary + func testWhenBrowserBookmarksImportSucceedsPasswordsImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 100, duplicate: 2, failed: 1)), + .passwords: .success(.init(successful: 13, duplicate: 42, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 100, duplicate: 2, failed: 1))), .init(.passwords, .success(.init(successful: 13, duplicate: 42, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, passwords failure -> file import + func testWhenBrowserBookmarksImportSucceedsPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 10, duplicate: 0, failed: 0)), + .passwords: .failure(Failure(.passwords, .decryptionError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, no passwords imported -> file import + func testWhenBrowserBookmarksImportSucceedsNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 10, duplicate: 0, failed: 0)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, no passwords file found -> file import + func testWhenBrowserBookmarksImportSucceedsNoPasswordsFileError_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 42, duplicate: 1, failed: 0)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 42, duplicate: 1, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, passwords (nil) -> boookmarks summary [Next] + func testWhenBrowserBookmarksOnlyImportSucceeds_bookmarksSummaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 42, duplicate: 1, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 42, duplicate: 1, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + func testWhenFileImportOpenPanelIsRejected_fileImportScreenRestored() async throws { + setupModel(with: .firefox, profiles: [BrowserProfile.test], screen: .fileImport(dataType: .bookmarks)) + + openPanelCallback = { _ in + nil + } + try await initiateImport(of: [.bookmarks], fromFile: .testHTML) + + let expectation = DataImportViewModel(importSource: .firefox, screen: .fileImport(dataType: .bookmarks)) + XCTAssertEqual(model.description, expectation.description) + } + + // MARK: Browser Sources: initial -> import -> bookmarks failure… + + // initial -> import -> bookmarks failure, passwords success -> file import + func testWhenBrowserPasswordsImportSucceedsBookmarksImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .decryptionError)), + .passwords: .success(.init(successful: 10, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.bookmarks, .failure(Failure(.bookmarks, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, no passwords imported -> file import + func testWhenBrowserBookmarksImportFailsNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .decryptionError)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.bookmarks, .failure(Failure(.bookmarks, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, passwords failure -> file import + func testWhenBrowserBookmarksImportFailsPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .dataCorrupted)), + .passwords: .failure(Failure(.passwords, .keychainError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted))), .init(.passwords, .failure(Failure(.passwords, .keychainError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, no passwords file found -> file import + func testWhenBrowserBookmarksImportFailsNoPasswordsFileError_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .dataCorrupted)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, passwords (nil) -> file import + func testWhenBrowserBookmarksOnlyImportSucceeds_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .dataCorrupted)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: Browser Sources: initial -> import -> no bookmarks… + + // initial -> import -> no bookmarks, passwords success -> file import + func testWhenBrowserNoBookmarksPasswordsImportSucceeds_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .success(.init(successful: 42, duplicate: 1, failed: 1)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .success(.init(successful: 42, duplicate: 1, failed: 1)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, passwords failuire -> file import + func testWhenBrowserNoBookmarksPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .failure(Failure(.passwords, .decryptionError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, no passwords -> file import + func testWhenBrowserNoBookmarksNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, no passwords file found -> file import + func testWhenBrowserNoBookmarksNoPasswordsFileFound_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, passwords (nil) -> file import + func testWhenBrowserNoBookmarksOnly_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: Browser Sources: initial -> import -> no bookmarks file found… + + // initial -> import -> no bookmarks file found, passwords success -> file import + func testWhenBrowserNoBookmarksFileFoundPasswordsImportSucceeds_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .success(.init(successful: 42, duplicate: 1, failed: 1)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 42, duplicate: 1, failed: 1))), .init(.bookmarks, .failure(Failure(.bookmarks, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, passwords failuire -> file import + func testWhenBrowserNoBookmarksFileFoundPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .failure(Failure(.passwords, .decryptionError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .noData))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, no passwords -> file import + func testWhenBrowserNoBookmarksFileFoundNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.bookmarks, .failure(Failure(.bookmarks, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, no passwords file found -> file import + func testWhenBrowserNoBookmarksFileFoundNoPasswordsFileFound_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .noData))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, passwords (nil) -> file import + func testWhenBrowserNoBookmarksFileFoundOnly_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: Browser Sources: initial -> import passwords… + + // initial -> import passwords -> passwords success -> summary + func testWhenBrowserOnlySelectedPasswordsImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .success(.init(successful: 1, duplicate: 2, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([.passwords]), summary: [.init(.passwords, .success(.init(successful: 1, duplicate: 2, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> passwords failure -> summary + func testWhenBrowserOnlySelectedPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .failure(Failure(.passwords, .dataCorrupted)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: [.init(.passwords, .failure(Failure(.passwords, .dataCorrupted)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> no passwords + func testWhenBrowserOnlySelectedPasswordsResultsWithNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> no passwords file found + func testWhenBrowserOnlySelectedPasswordsImportResultsWithNoPasswordsFileFound_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: [.init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> only file import supported for passwords -> [Next] -> file import + func testWhenBrowserOnlySelectedPasswordsCannotBeImported_manualImportSuggested() throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], dataImporterFactory: { src, dataType, _, _ in + XCTAssertEqual(src, source) + XCTAssertNil(dataType) + return ImporterMock(importableTypes: [.bookmarks]) + }) + + model.selectedDataTypes = [.passwords] + + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords)), source.rawValue) + XCTAssertEqual(model.secondaryButton, .cancel, source.rawValue) + + model.performAction(model.actionButton!) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: []) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: - File Import Sources + + // csv/html file import succeeds -> summary + func testWhenFileImportSourceImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen.isFileImport { + setupModel(with: source) + + guard case .fileImport(dataType: let dataType, summary: []) = model.screen else { + XCTFail("\(source): unexpected initial screen: \(model.screen)") + continue + } + + XCTAssertEqual([dataType], source.supportedDataTypes) + XCTAssertEqual(model.selectedDataTypes, [dataType], source.rawValue) + XCTAssertEqual(model.buttons, [.cancel], source.rawValue) + + try await initiateImport(of: [dataType], fromFile: dataType == .passwords ? .testCSV : .testHTML, resultingWith: [ + dataType: .success(.init(successful: 42, duplicate: 12, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([dataType]), summary: [.init(dataType, .success(.init(successful: 42, duplicate: 12, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // csv/html file import succeeds with 0 passwords/bookmarks imported -> summary + func testWhenFileImportSourceImportSucceedsWithNoDataFound_summaryShown() async throws { + for source in Source.allCases where source.initialScreen.isFileImport { + setupModel(with: source) + + guard case .fileImport(dataType: let dataType, summary: []) = model.screen else { + XCTFail("\(source): unexpected initial screen: \(model.screen)") + continue + } + + XCTAssertEqual(model.selectedDataTypes, [dataType], source.rawValue) + XCTAssertEqual(model.buttons, [.cancel], source.rawValue) + + try await initiateImport(of: [dataType], fromFile: dataType == .passwords ? .testCSV : .testHTML, resultingWith: [ + dataType: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([dataType]), summary: [.init(dataType, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // csv/html file import fails -> feedback + func testWhenFileImportSourceImportFails_feedbackScreenShown() async throws { + for source in Source.allCases where source.initialScreen.isFileImport { + setupModel(with: source) + + guard case .fileImport(dataType: let dataType, summary: []) = model.screen else { + XCTFail("\(source): unexpected initial screen: \(model.screen)") + continue + } + + XCTAssertEqual(model.selectedDataTypes, [dataType], source.rawValue) + XCTAssertEqual(model.buttons, [.cancel], source.rawValue) + + try await initiateImport(of: [dataType], fromFile: dataType == .passwords ? .testCSV : .testHTML, resultingWith: [ + dataType: .failure(Failure(dataType.importAction, .dataCorrupted)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [.init(dataType, .failure(Failure(dataType.importAction, .dataCorrupted)))]) + XCTAssertEqual(model.description, expectation.description) + } } - func initiateImport(of dataTypes: Set, from profile: BrowserProfile? = nil, fromFile url: URL? = nil, resultingWith getResult: @escaping @autoclosure () -> DataImportSummary, file: StaticString = #filePath, line: UInt = #line, progress progressUpdateCallback: ((DataImportProgressEvent) -> Void)? = nil) async throws { - assert((profile != nil) != (url != nil), "must provide either profile or url") + // MARK: - File Import after failure (or nil result for a data type) + + // all possible import summaries for combining + let bookmarksSummaries: [DataImportViewModel.DataTypeImportResult?] = [ + // bookmarks import didn‘t happen (or skipped) + nil, + // bookmarks import succeeded + .init(.bookmarks, .success(.init(successful: 42, duplicate: 3, failed: 1))), + // bookmarks import succeeded with no bookmarks imported + .init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), + // bookmarks import failed with error + .init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted))), + // bookmarks import failed with file not found + .init(.bookmarks, .failure(Failure(.bookmarks, .noData))), + ] + + let bookmarksResults: [DataImportResult?] = [ + .failure(Failure(.bookmarks, .dataCorrupted)), + .success(.init(successful: 5, duplicate: 4, failed: 3)), + .success(.init(successful: 0, duplicate: 0, failed: 0)), + nil, // skip + ] + + let passwordsSummaries: [DataImportViewModel.DataTypeImportResult?] = [ + // passwords import didn‘t happen (or skipped) + nil, + // passwords import succeeded + .init(.passwords, .success(.init(successful: 99, duplicate: 4, failed: 2))), + // passwords import succeeded with no passwords imported + .init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0))), + // passwords import failed with error + .init(.passwords, .failure(Failure(.passwords, .keychainError))), + // passwords import failed with file not found + .init(.passwords, .failure(Failure(.passwords, .noData))), + ] + + let passwordsResults: [DataImportResult?] = [ + .failure(Failure(.passwords, .dataCorrupted)), + .success(.init(successful: 6, duplicate: 3, failed: 1)), + .success(.init(successful: 0, duplicate: 0, failed: 0)), + nil, // skip + ] + + func testWhenBrowsersBookmarksFileImportSucceedsAndNoPasswordsFileImportNeeded_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords successfully imported + where (try? passwordsSummary?.result.get().isEmpty) == false { + + for result in bookmarksResults + // bookmarks file import successful (incl. empty), or skipped when initial result was empty + where result?.isSuccess == true || (result == nil && (try? bookmarksSummary?.result.get())?.isEmpty == true) { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks, .passwords]), summary: [bookmarksSummary, passwordsSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + } - let source = model.importSource - let message: () -> String = { "\(source): \(profile?.profileName ?? url!.path)" } + func testWhenBrowsersOnlySelectedBookmarksFileImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { - for dataType in DataType.allCases where model.selectedDataTypes.contains(dataType) != dataTypes.contains(dataType) { - model.setDataType(dataType, selected: dataTypes.contains(dataType)) - } - XCTAssertEqual(model.selectedDataTypes, dataTypes, message().with("selectedDataTypes"), file: file, line: line) + for result in bookmarksResults + // bookmarks file import successful (incl. empty) + where result?.isSuccess == true { - if let profile { - selectProfile(profile, message(), file: file, line: line) - } + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary].compactMap { $0 }) - self.importTask = { _ in getResult() } -// TODO: test cancel/back - var model: DataImportViewModel = self.model - if let url { - XCTAssertEqual(model.actionButton, .initiateImport(disabled: true), message().with("actionButton"), file: file, line: line) - model.initiateImport(fileURL: url) - } else { - XCTAssertEqual(model.actionButton, .initiateImport(disabled: false), message().with("actionButton"), file: file, line: line) - model.performAction(.initiateImport(disabled: false)) - } - self.model = model + if source.supportedDataTypes.contains(.passwords) { + model.selectedDataTypes = [.bookmarks] + } - struct NoProgress: Error {} - guard let importProgress = model.importProgress else { XCTAssertNotNil(model.importProgress, message().with("importProgress"), file: file, line: line); throw NoProgress() } + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") result: \(result?.description ?? ".skip")" - let taskStarted = expectation(description: "import task started") - let taskCompleted = expectation(description: "import task completed") + // run File Import + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result!], xctDescr) - let task = Task { - taskStarted.fulfill() + xctDescr = "\(source): " + xctDescr - for await event in importProgress { - switch event { - case .progress(let progressEvent): - progressUpdateCallback?(progressEvent) - case .completed(.success(let newModel)): - taskCompleted.fulfill() + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks]), summary: [bookmarksSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } - return newModel + func testWhenBrowsersBookmarksFileImportFailsAndNoPasswordsFileImportNeeded_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords successfully imported + where (try? passwordsSummary?.result.get().isEmpty) == false { + + for result in bookmarksResults + // bookmarks file import failed or skipped when initial result was a failure + where result?.isSuccess == false || (result == nil && bookmarksSummary?.result.isSuccess == false) { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [bookmarksSummary, passwordsSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } } } - return nil } + } - await fulfillment(of: [taskStarted, taskCompleted], timeout: 0.5) + func testWhenBrowsersOnlySelectedBookmarksFileImportFails_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { - self.model = try await task.value ?? { throw CancellationError() }() - } + for result in bookmarksResults + // bookmarks file import successful (incl. empty) + where result?.isSuccess == false { - func expect(_ screen: DataImportViewModel.Screen, actionButton: DataImportViewModel.ButtonType? = nil, secondaryButton: DataImportViewModel.ButtonType? = nil, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary].compactMap { $0 }) - XCTAssertEqual(model.screen, screen, message().with("screen"), file: file, line: line) - XCTAssertEqual(model.actionButton, actionButton, message().with("actionButton"), file: file, line: line) - XCTAssertEqual(model.secondaryButton, secondaryButton, message().with("secondaryButton"), file: file, line: line) - XCTAssertNil(model.importProgress, message().with("importProgress"), file: file, line: line) + if source.supportedDataTypes.contains(.passwords) { + model.selectedDataTypes = [.bookmarks] + } - // auto test cancel/back/done - for button in Set([actionButton, secondaryButton]).intersection(model.buttons).compactMap({ $0 }) { - var model = model! - switch button { - case .cancel, .done: - let e = expectation(description: message().with("dismiss called")) - model.performAction(for: button, dismiss: { - e.fulfill() - }) - waitForExpectations(timeout: 0) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") result: \(result?.description ?? ".skip")" - case .back: - let initialSource = model.importSource - let initialProfiles = model.browserProfiles - let initialProfile = model.selectedProfile - let initialScreen = initialSource.initialScreen - model.performAction(button) - XCTAssertEqual(model.screen, initialScreen, message().with("Back - initialScreen"), file: file, line: line) - XCTAssertEqual(model.browserProfiles?.profiles, initialProfiles?.profiles, message().with("Back - initialProfiles"), file: file, line: line) - XCTAssertEqual(model.selectedProfile, initialProfile, message().with("Back - initialProfile"), file: file, line: line) - case .submit: - // TODO: test submit - fatalError() - default: - break + // run File Import + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result!], xctDescr) + + xctDescr = "\(source): " + xctDescr + + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [bookmarksSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } } } - - // TODO: } - func testDataImportSourceSupportedDataTypes() { - for source in Source.allCases { - if source.initialScreen == .profileAndDataTypesPicker { - if source == .tor { - XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) - } else { - XCTAssertEqual(source.supportedDataTypes, [.bookmarks, .passwords], source.importSourceName) + func testWhenBrowsersBookmarksFileImportFailsAndPasswordsFileImportIsNeeded_bookmarksSummaryWithNextButtonShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords failed to import, not imported or empty data + where passwordsSummary?.result.isSuccess != true || (try? passwordsSummary?.result.get().isEmpty) == true { + + for result in bookmarksResults + // any bookmarks file import result except Skip + where result != nil { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result!], xctDescr) + + xctDescr = "\(source): " + xctDescr + + // expect Bookmarks Import Summary screen + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks]), summary: [bookmarksSummary, passwordsSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + // [Next] -> passwords file import screen + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords)), xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } } - } else { - if source == .bookmarksHTML { - XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) - } else { - XCTAssertEqual(source.supportedDataTypes, [.passwords], source.importSourceName) + } + } + } + + func testWhenBrowsersBookmarksFileImportSkippedAndPasswordsFileImportIsNeeded_passwordsFileImportShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords failed to import, not imported or empty data + where passwordsSummary?.result.isSuccess != true || (try? passwordsSummary?.result.get().isEmpty) == true { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "")" + + // skip Bookmarks file import + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + + xctDescr = "\(source): " + xctDescr + + // expect Bookmarks Import Summary screen + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + // if no failures: Cancel button is shown + if bookmarksSummary?.result.isSuccess != false && passwordsSummary?.result.isSuccess != false { + XCTAssertEqual(model.actionButton, .cancel, xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip, xctDescr) + } + XCTAssertNil(model.secondaryButton, xctDescr) } } } } - func testWhenBrowserPasswordsImportFails_manualImportSuggested() async throws { - for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { - guard let browser = ThirdPartyBrowser.browser(for: source) else { - XCTFail("no ThirdPartyBrowser for \(source)") - continue + // MARK: File import after passwords failure + + func testWhenBrowsersPasswordsFileImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries { + + for passwordsSummary in passwordsSummaries + // passwords import failed or empty + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get().isEmpty) == false { + + for bookmarksFileImportSummary in bookmarksSummaries + // if bookmarks result was failure - append successful bookmarks file import result + where (bookmarksSummary?.result.isSuccess == false && bookmarksFileImportSummary?.result.isSuccess == true) + // if bookmarks file import summary was successful and non empty - don‘t append bookmarks file import result + // or if bookmarks file import was empty - and bookmarks file import skipped + || (bookmarksSummary?.result.isSuccess == true && bookmarksFileImportSummary == nil) { + + for result in passwordsResults + // passwords file import successful (incl. empty), or skipped when initial result was empty + where result?.isSuccess == true || (result == nil && (try? passwordsSummary?.result.get())?.isEmpty == true) { + + // setup model with pre-failed passwords import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? ""), bookmarksFileSummary: \(bookmarksFileImportSummary?.description ?? ".skip") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks, .passwords]), summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } } + } + } - setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + func testWhenBrowsersOnlySelectedPasswordsFileImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for passwordsSummary in passwordsSummaries + // initial passwords import failed or ended with empty result + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get())?.isEmpty == true { - try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ - .bookmarks: .success(.init(successful: 10, duplicate: 0, failed: 0)), - .passwords: .failure(Failure(.passwords, .decryptionError)) - ]) + for result in passwordsResults + // passwords file import successful (incl. empty) + where result?.isSuccess == true { - let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) - XCTAssertEqual(model.description, expectation.description) - } - } + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [passwordsSummary].compactMap { $0 }) - + model.selectedDataTypes = [.passwords] -// func testWhenManualImportChosenForPasswords_csvFileImportScreenIsShown() async throws { -// for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { -// guard let browser = ThirdPartyBrowser.browser(for: source) else { -// XCTFail("no ThirdPartyBrowser for \(source)") -// continue -// } -// -// setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], screen: .error(dataType: .passwords, errorType: .decryptionError), summary: [ -// .init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), -// .init(.passwords, .failure(MockImportError(action: .passwords, errorType: .decryptionError))) -// ]) -// -// model.performAction(.manualImport) -// // TODO: tor does not support passwords -// expect(.fileImport(.passwords), actionButton: .skip, secondaryButton: .back, "\(source)") -// } -// } - -// try await testBranch { -// try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [ -// .passwords: .success(.init(successful: 1, duplicate: 2, failed: 42)) // TODO: failure alternative -// ]) -// expect(.summary, actionButton: .done, secondaryButton: .done, "\(source) - Manual Import - Success") -// -// } alternative: { -// model.performAction(.skip) -// expect(.summary, actionButton: .done, secondaryButton: .back, "\(source) - Manual Import - Skip") -// } -// -// } alternative: { -// model.performAction(.skip) -// expect(.summary, actionButton: .done, secondaryButton: .back, "\(source) - Skip") -// } -// -// func testBrowserDataImport() async throws { -// for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { -// guard let browser = ThirdPartyBrowser.browser(for: source) else { -// XCTFail("no ThirdPartyBrowser for \(source)") -// continue -// } -// -// setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) -// -// try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: browser), resultingWith: [ -// .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), -// .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)) -// ]) -// -// expect(.error(dataType: .bookmarks, errorType: .noData), actionButton: .manualImport, secondaryButton: .skip, "\(source)") -// } -// } -// -// func testGenericDataImport() async { -// for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil || source.initialScreen != .profileAndDataTypesPicker { -// model = DataImportViewModel(importSource: source, loadProfiles: { XCTFail("Unexpected loadProfiles"); return .init(browser: $0, profiles: []) }, dataImporterFactory: dataImporter) -// XCTAssertEqual(source.supportedDataTypes.count, 1) -// expect(.fileImport(source.supportedDataTypes.first!), actionButton: .initiateImport(disabled: true), secondaryButton: .cancel) -// -// } -// } -// TODO: test skip - // MARK: - Tests + var xctDescr = "passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" -// func testWhenPreferredImportSourcesAvailable_firstPreferredSourceIsSelected() { -// model = DataImportViewModel(availableImportSources: [.safari, .csv, .bitwarden], preferredImportSources: [.firefox, .chrome, .bitwarden, .safari]) -// XCTAssertEqual(model.importSource, .bitwarden) -// } -// -// func testWhenModelIsInstantiated_initialScreenIsShown() { -// for source in Source.allCases { -// model = DataImportViewModel(importSource: source) -// XCTAssertEqual(model.screen, source.initialScreen, "\(source)") -// } -// } - -// func testWhenNextButtonIsClicked_screenForTheButtonIsShown() { -// model = DataImportViewModel(importSource: .safari) -// model.performAction(.next(.fileImport(.bookmarks))) -// XCTAssertEqual(model.screen, .fileImport(.bookmarks)) -// } -// -// // MARK: Browser profiles -// -// func testWhenNoProfilesAreLoaded_selectedProfileIsNil() { -// model = DataImportViewModel(importSource: .safari, loadProfiles: { source in -// XCTAssertEqual(source, .safari) -// return .init(browser: source, profiles: []) -// }) -// XCTAssertNil(model.selectedProfile) -// } -// -// func testWhenProfilesAreLoaded_DefaultProfileIsSelected() { -// model = DataImportViewModel(importSource: .firefox, loadProfiles: { source in -// XCTAssertEqual(source, .firefox) -// return .init(browser: source, profiles: [.test(for: source), .default(for: source)]) -// }) -// XCTAssertEqual(model.selectedProfile, .default(for: .firefox)) -// } -// -// func testWhenImportSourceChanged_AnotherDefaultProfileIsSelected() { -// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .default(for: $0) ]) }) -// model.update(with: .chromium) -// XCTAssertEqual(model.selectedProfile, .default(for: .chrome)) -// } -// -// func testWhenNoDefaultProfileIsLoaded_firstProfileIsSelected() { -// model = DataImportViewModel(importSource: .chrome, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .test2(for: $0) ]) }) -// XCTAssertEqual(model.selectedProfile, .test(for: .chrome)) -// } -// // TODO: test switching from all generic to all browser and from all browsers to all generic and from all browsers to all browsers and from all generics to all generics -// -// // MARK: Import from browser profile -// -// // MARK: Buttons -// // TODO: when importer.importableTypes does not contain as selected type next screen should be file import -// func testWhenProfilesAreLoadedAndImporterCanImportStraightAway_buttonActionsAreCancelAndImport() { -// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in -// ImporterMock() -// }) -// -// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.actionButton, .initiateImport) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPassword_buttonActionsAreCancelAndMoreInfo() { -// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in -// ImporterMock(keychainPasswordRequiredFor: [.passwords]) -// }) -// -// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.actionButton, .next(.moreInfo)) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPasswordButPasswordsDataTypeNotSelected_buttonActionsAreCancelAndImport() { -// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in -// ImporterMock(keychainPasswordRequiredFor: [.passwords]) -// }) -// model.setDataType(.passwords, selected: false) -// -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.actionButton, .initiateImport) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenNoBrowserForImportSource_buttonActionsAreCancelAndNone() { -// for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil { -// model = DataImportViewModel(importSource: source, loadProfiles: { -// XCTFail("Unexpected loadProfiles") -// return .init(browser: $0, profiles: [.test(for: $0)]) -// }) -// -// XCTAssertEqual(model.selectedDataTypes, source.supportedDataTypes, "\(source)") -// XCTAssertNil(model.actionButton) -// XCTAssertEqual(model.secondaryButton, .cancel, "\(source)") -// XCTAssertFalse(model.isSecondaryButtonDisabled, "\(source)") -// } -// } -// -// func testWhenNoProfilesAreLoaded_buttonActionsAreCancelAndProceedToFileImport() { -// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) -// -// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.actionButton, .next(.fileImport(.bookmarks))) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenNoProfilesAreLoadedAndBookmarksDataTypeUnselected_fileImportDataTypeChanges() { -// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) -// -// model.setDataType(.bookmarks, selected: false) -// -// XCTAssertEqual(model.selectedDataTypes, [.passwords]) -// XCTAssertEqual(model.actionButton, .next(.fileImport(.passwords))) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenPasswordsDataTypeUnselected_fileImportDataTypeChanges() { -// model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) -// -// model.setDataType(.passwords, selected: false) -// -// XCTAssertEqual(model.selectedDataTypes, [.bookmarks]) -// XCTAssertEqual(model.actionButton, .next(.fileImport(.bookmarks))) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenNoDataTypeSelected_actionButtonDisabled() { -// model = DataImportViewModel(importSource: .safari) -// -// model.setDataType(.bookmarks, selected: false) -// model.setDataType(.passwords, selected: false) -// -// XCTAssertEqual(model.selectedDataTypes, []) -// XCTAssertEqual(model.actionButton, .initiateImport) -// XCTAssertTrue(model.isActionButtonDisabled) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenImportSourceChanges_selectedDataTypesAreReset() { -// model = DataImportViewModel(importSource: .safari) -// -// model.setDataType(.bookmarks, selected: false) -// model.setDataType(.passwords, selected: false) -// -// model.update(with: .brave) -// -// XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.actionButton, .next(.fileImport(.bookmarks))) -// XCTAssertEqual(model.secondaryButton, .cancel) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenBookmarksImportFails_failureMessageIsShown() async throws { -// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: self.dataImporter) -// -// let model = try await initiateImport { _ in -// // TODO: test [:] -// // TODO: test bookmarks .success with 0 bookmarks -// return [.bookmarks: .failure(MockImportError(action: .bookmarks, errorType: .decryptionError)), -// .passwords: .failure(MockImportError(action: .passwords, errorType: .decryptionError))] -// } -// -// XCTAssertEqual(model.importSource, .safari) -// XCTAssertEqual(model.summary, [ -// .init(.bookmarks, .failure(MockImportError(action: .bookmarks))), -// .init(.passwords, .failure(MockImportError(action: .passwords))), -// ]) -// XCTAssertEqual(model.screen, .error(dataType: .bookmarks, errorType: .decryptionError)) -// XCTAssertEqual(model.actionButton, .manualImport) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.secondaryButton, .skip) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenBookmarksImportFailsAndSkipButtonIsClicked_passwordsImportFailureMessageIsShown() async throws { -// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: self.dataImporter) -// -// let m1 = try await initiateImport { _ in -// return [.bookmarks: .failure(MockImportError(action: .bookmarks, errorType: .decryptionError)), -// .passwords: .failure(MockImportError(action: .passwords, errorType: .keychainError))] -// } -// model = m1 -// -// model.performAction(.skip) -// -// XCTAssertEqual(model.importSource, .safari) -// XCTAssertEqual(model.summary, [ -// .init(.bookmarks, .failure(MockImportError(action: .bookmarks))), -// .init(.passwords, .failure(MockImportError(action: .passwords))), -// ]) -// XCTAssertEqual(model.screen, .error(dataType: .passwords, errorType: .keychainError)) -// XCTAssertEqual(model.actionButton, .manualImport) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.secondaryButton, .skip) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } -// -// func testWhenBookmarksImportFailsAndManualImportButtonIsClicked_passwordsImportFailureMessageIsShown() async throws { -// model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: self.dataImporter) -// -// let m1 = try await initiateImport { _ in -// return [.bookmarks: .failure(MockImportError(action: .bookmarks, errorType: .decryptionError)), -// .passwords: .failure(MockImportError(action: .passwords, errorType: .keychainError))] -// } -// model = m1 -// -// model.performAction(.skip) -// -// XCTAssertEqual(model.importSource, .safari) -// XCTAssertEqual(model.summary, [ -// .init(.bookmarks, .failure(MockImportError(action: .bookmarks))), -// .init(.passwords, .failure(MockImportError(action: .passwords))), -// ]) -// XCTAssertEqual(model.screen, .error(dataType: .passwords, errorType: .keychainError)) -// XCTAssertEqual(model.actionButton, .manualImport) -// XCTAssertFalse(model.isActionButtonDisabled) -// XCTAssertEqual(model.secondaryButton, .skip) -// XCTAssertFalse(model.isSecondaryButtonDisabled) -// } + // run File Import + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result!], xctDescr) - // TODO: .bkm: .success, .passwd: .fail -> skip - // TODO: .bkm: .fail, .passwd: .success -> skip - // TODO: .bkm: .success, .passwd: .fail -> manual - // TODO: .bkm: .fail, .passwd: .success -> manual + xctDescr = "\(source): " + xctDescr - // TODO: test progress - func whenRequiresPrimaryPassword_passwordIsRequested() { + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.passwords]), summary: [passwordsSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + func testWhenBrowsersPasswordsFileImportFails_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries { + + for passwordsSummary in passwordsSummaries + // passwords import failed or empty + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get().isEmpty) == false { + + for bookmarksFileImportSummary in bookmarksSummaries + // if bookmarks result was failure - append successful bookmarks file import result + where (bookmarksSummary?.result.isSuccess == false && bookmarksFileImportSummary?.result.isSuccess == true) + // if bookmarks file import summary was successful and non empty - don‘t append bookmarks file import result + // or if bookmarks file import was empty - and bookmarks file import skipped + || (bookmarksSummary?.result.isSuccess == true && bookmarksFileImportSummary == nil) { + + for result in passwordsResults + // passwords file import failed or skipped when initial result was a failure + where result?.isSuccess == false || (result == nil && passwordsSummary?.result.isSuccess == false) { + + // setup model with pre-failed passwords import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? ""), bookmarksFileSummary: \(bookmarksFileImportSummary?.description ?? ".skip") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } + } + } + } + } } - func testImport() { + func testWhenBrowsersOnlySelectedPasswordsFileImportFails_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for passwordsSummary in passwordsSummaries + // initial passwords import failed or ended with empty result + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get())?.isEmpty == true { - } + for result in passwordsResults + // passwords file import failed + where result?.isSuccess == false { - func testFailureImportFileSucceeds() { + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [passwordsSummary].compactMap { $0 }) - } + model.selectedDataTypes = [.passwords] - func testFailureImportFileFails() { + var xctDescr = "passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" - } + // run File Import + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result!], xctDescr) - func test2xFailureImportFileSucceedsPasswordsFails() { + xctDescr = "\(source): " + xctDescr + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [passwordsSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } + } + } } - func test2xFailureImportFileSucceedsBookmarksFails() { + // MARK: - Feedback + + func testFeedbackSending() { + let summary: [DataImportViewModel.DataTypeImportResult] = [ + .init(.bookmarks, .success(.empty)), + .init(.bookmarks, .failure(Failure(.passwords, .dataCorrupted))), + .init(.passwords, .failure(Failure(.passwords, .keychainError))), + .init(.passwords, .failure(Failure(.passwords, .dataCorrupted))) + ] + let e = expectation(description: "report sent") + model = DataImportViewModel(importSource: .safari, screen: .feedback, summary: summary, reportSenderFactory: { + { report in + XCTAssertEqual(report.text, "Test text") + for dataTypeSummary in summary { + if case .failure(let error) = dataTypeSummary.result { + XCTAssertTrue(report.error.localizedDescription.contains(error.localizedDescription)) + } + } + XCTAssertEqual(report.importSourceDescription, Source.safari.importSourceName) + XCTAssertEqual(report.appVersion, "\(AppVersion.shared.versionNumber)") + XCTAssertEqual(report.osVersion, "\(ProcessInfo.processInfo.operatingSystemVersion)") + XCTAssertEqual(report.retryNumber, 2) + XCTAssertEqual(report.importSource, .safari) - } + e.fulfill() + } + }) + + XCTAssertEqual(model.buttons, [.cancel, .submit]) - // TODO: and other combination: table of truth + model.reportModel.text = "Test text" - // TODO: when import source changes selected profile is reset - // TODO: when import source changes user report text is preserved - // TODO: when another error after back reported error is updated + let eDismissed = expectation(description: "dismissed") + model.performAction(for: .submit) { + eDismissed.fulfill() + } + waitForExpectations(timeout: 0) + } // MARK: - Helpers - private var importTask: ((DataImportProgressCallback) async -> DataImportSummary)! + var openPanelCallback: ((DataType) -> URL?)? + + func setupModel(with source: Source, profiles: [(ThirdPartyBrowser) -> BrowserProfile] = [], screen: DataImportViewModel.Screen? = nil, summary: [DataImportViewModel.DataTypeImportResult] = [], dataImporterFactory: DataImportViewModel.DataImporterFactory? = nil, requestPrimaryPasswordCallback: ((DataImportViewModel.Source) -> String?)? = nil) { + model = DataImportViewModel(importSource: source, screen: screen, summary: summary, loadProfiles: { browser in + .init(browser: browser, profiles: profiles.map { $0(browser) }) { _ in + { // swiftlint:disable:this opening_brace + .init(logins: .available, bookmarks: .available) + } + } + }, dataImporterFactory: dataImporterFactory ?? self.dataImporter, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback ?? { _ in nil }, openPanelCallback: { self.openPanelCallback!($0) }) + } + + func selectProfile(_ profile: BrowserProfile, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(model.browserProfiles?.validImportableProfiles.contains(profile) == true, message().with("profile not available"), file: file, line: line) + model.selectedProfile = profile + } private func dataImporter(for source: DataImport.Source, fileDataType: DataImport.DataType?, url: URL, primaryPassword: String?) -> DataImporter { XCTAssertEqual(source, model.importSource) @@ -533,56 +1533,88 @@ final class DataImportViewModelTests: XCTestCase { return ImporterMock(password: primaryPassword, importTask: self.importTask) } -// func expectButtons(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) where T : Equatable { -// -// } - -} - -private class ImporterMock: DataImporter { - - var password: String? + private var importTask: ((Set, DataImportProgressCallback) async -> DataImportSummary)! + func initiateImport(of dataTypes: Set, from profile: BrowserProfile? = nil, fromFile url: URL? = nil, resultingWith result: DataImportSummary? = nil, file: StaticString = #filePath, line: UInt = #line, _ descr: String? = nil, progress progressUpdateCallback: ((DataImportProgressEvent) -> Void)? = nil) async throws { + assert((profile != nil) != (url != nil), "must provide either profile or url") - var importableTypes: [DataImport.DataType] + let source = model.importSource + let message: () -> String = { "\(source): \(profile?.profileName ?? url!.path)".with(descr) } - var keychainPasswordRequiredFor: Set + if [.profileAndDataTypesPicker, .moreInfo].contains(model.screen) { + for dataType in DataType.allCases where model.selectedDataTypes.contains(dataType) != dataTypes.contains(dataType) { + model.setDataType(dataType, selected: dataTypes.contains(dataType)) + } + XCTAssertEqual(model.selectedDataTypes, dataTypes, message().with("selectedDataTypes"), file: file, line: line) - init(password: String? = nil, importableTypes: [DataImport.DataType] = [.bookmarks, .passwords], keychainPasswordRequiredFor: Set = [], accessValidator: ((Set) -> [DataImport.DataType: any DataImportError]?)? = nil, importTask: ((DataImportProgressCallback) async -> DataImportSummary)? = nil) { - self.password = password - self.importableTypes = importableTypes - self.keychainPasswordRequiredFor = keychainPasswordRequiredFor - self.accessValidator = accessValidator - self.importTask = importTask - } - func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { - selectedDataTypes.intersects(keychainPasswordRequiredFor) - } + if let profile { + selectProfile(profile, message(), file: file, line: line) + } + } else { + XCTAssertNil(profile, message().with("profile"), file: file, line: line) + } - var accessValidator: ((Set) -> [DataImport.DataType: any DataImportError]?)? + if let result { + self.importTask = { _, _ in result } + } - func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { - accessValidator?(types) - } + var model: DataImportViewModel = self.model + if let url { // file import + XCTAssertEqual(dataTypes.count, 1, message().with("actionButton"), file: file, line: line) + if !source.isBrowser { + XCTAssertNil(model.actionButton) + } else if model.selectedDataTypes.isDisjoint(with: DataType.dataTypes(after: dataTypes.first!)), + model.summary(for: dataTypes.first!)?.isEmpty == true { + // no more data types available and import result is .success(.empty) + XCTAssertEqual(model.actionButton, .cancel, message().with("actionButton"), file: file, line: line) + } else if model.selectedDataTypes.isDisjoint(with: DataType.dataTypes(after: dataTypes.first!)), + !model.summary.contains(where: { $0.result.isSuccess == false }) { + // when no errors collected before - Cancel would be shown instead of Skip for Passwords Import + XCTAssertEqual(model.actionButton, .cancel) + } else if case .profileAndDataTypesPicker = model.importSource.initialScreen { + XCTAssertEqual(model.actionButton, .skip, message().with("actionButton"), file: file, line: line) + } - var importTask: ((DataImportProgressCallback) async -> DataImportSummary)? + if openPanelCallback == nil { + openPanelCallback = { dataType in + XCTAssertEqual(dataType, dataTypes.first!, message().with("file import dataType"), file: file, line: line) + self.openPanelCallback = nil + return url + } + } + model.selectFile() - func importData(types: Set) -> DataImportTask { - .detachedWithProgress { [importTask=importTask!]updateProgress in - await importTask(updateProgress) + } else { + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false), message().with("actionButton"), file: file, line: line) + model.performAction(.initiateImport(disabled: false)) } - } + self.model = model -} + while let importProgress = self.model.importProgress { + let taskStarted = expectation(description: "import task started") + let taskCompleted = expectation(description: "import task completed") -private extension URL { - static let mockURL = URL(fileURLWithPath: "/Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/") + let task = Task { + taskStarted.fulfill() - static let testCSV = URL(fileURLWithPath: "/Users/Dax/Downloads/passwords.csv") - static let testHTML = URL(fileURLWithPath: "/Users/Dax/Downloads/bookmarks.html") + for await event in importProgress { + switch event { + case .progress(let progressEvent): + progressUpdateCallback?(progressEvent) + case .completed(.success(let newModel)): + taskCompleted.fulfill() - static func profile(named name: String) -> URL { - return mockURL.appendingPathComponent(name) + return newModel + } + } + return nil + } + + await Task.yield() + self.model = try await task.value ?? { throw CancellationError() }() + await fulfillment(of: [taskStarted, taskCompleted], timeout: 0.0) + } } + } private extension DataImport.BrowserProfile { @@ -592,6 +1624,9 @@ private extension DataImport.BrowserProfile { static func test2(for browser: ThirdPartyBrowser) -> Self { .init(browser: browser, profileURL: .profile(named: "Test Profile 2")) } + static func test3(for browser: ThirdPartyBrowser) -> Self { + .init(browser: browser, profileURL: .profile(named: "Test Profile 3")) + } static func `default`(for browser: ThirdPartyBrowser) -> Self { switch browser { case .firefox, .tor: @@ -627,44 +1662,107 @@ private struct Failure: DataImportError, CustomStringConvertible { } -extension DataImportViewModel.ButtonType: CustomStringConvertible { - public var description: String { - switch self { - case .next(let screen): "next(\(screen))" - case .initiateImport(disabled: let disabled): "initiateImport\(disabled ? "(disabled)" : "")" - case .skip: "skip" - case .cancel: "cancel" - case .back: "back" - case .done: "done" - case .submit: "submit" +private class ImporterMock: DataImporter { + + var password: String? + + var importableTypes: [DataImport.DataType] + + var keychainPasswordRequiredFor: Set + + init(password: String? = nil, importableTypes: [DataImport.DataType] = [.bookmarks, .passwords], keychainPasswordRequiredFor: Set = [], accessValidator: ((ImporterMock, Set) -> [DataImport.DataType: any DataImportError]?)? = nil, importTask: ((Set, DataImportProgressCallback) async -> DataImportSummary)? = nil) { + self.password = password + self.importableTypes = importableTypes + self.keychainPasswordRequiredFor = keychainPasswordRequiredFor + self.accessValidator = accessValidator + self.importTask = importTask + } + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + selectedDataTypes.intersects(keychainPasswordRequiredFor) + } + + var accessValidator: ((ImporterMock, Set) -> [DataImport.DataType: any DataImportError]?)? + + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + accessValidator?(self, types) + } + + var importTask: ((Set, DataImportProgressCallback) async -> DataImportSummary)? + + func importData(types: Set) -> DataImportTask { + .detachedWithProgress { [importTask=importTask!]updateProgress in + await importTask(types, updateProgress) } } + +} + +extension DataImportViewModel { + @MainActor mutating func performAction(_ buttonType: ButtonType) { + performAction(for: buttonType, dismiss: { assertionFailure("Unexpected dismiss") }) + } +} + +extension DataImportViewModel: CustomStringConvertible { + public var description: String { + "DataImportViewModel(importSource: .\(importSource.rawValue), screen: \(screen)\(!summary.isEmpty ? ", summary: \(summary)" : ""))" + } } extension DataImportViewModel.Screen: CustomStringConvertible { public var description: String { switch self { - case .profileAndDataTypesPicker: "profileAndDataTypesPicker" - case .moreInfo: "moreInfo" + case .profileAndDataTypesPicker: ".profileAndDataTypesPicker" + case .moreInfo: ".moreInfo" case .getReadPermission(let url): "getReadPermission(\(url.path))" - case .fileImport(let dataType, summary: let summary): "fileImport(\(dataType), \(summary))" - case .summary(let dataTypes): "summary(\(dataTypes))" - case .feedback: "feedback" + case .fileImport(dataType: let dataType, summary: let summaryDataTypes): ".fileImport(dataType: .\(dataType)\(!summaryDataTypes.isEmpty ? ", summary: [\(summaryDataTypes.map { "." + $0.rawValue }.sorted().joined(separator: ", "))]" : ""))" + case .summary(let dataTypes): ".summary([\(dataTypes.map { "." + $0.rawValue }.sorted().joined(separator: ", "))])" + case .feedback: ".feedback" } } } -private extension String { +extension DataImportViewModel.ButtonType: CustomStringConvertible { + public var description: String { + switch self { + case .next(let screen): ".next(\(screen))" + case .initiateImport(disabled: let disabled): ".initiateImport\(disabled ? "(disabled)" : "")" + case .skip: ".skip" + case .cancel: ".cancel" + case .back: ".back" + case .done: ".done" + case .submit: ".submit" + } + } +} - func with(_ addition: String) -> String { - guard !self.isEmpty else { return addition } - return self + " - " + addition +extension DataImportViewModel.DataTypeImportResult: CustomStringConvertible { + public var description: String { + ".init(.\(dataType), \(result))" + } +} + +extension DataImport.DataTypeSummary: CustomStringConvertible { + public var description: String { + ".init(successful: \(successful), duplicate: \(duplicate), failed: \(failed))" } +} +private extension String { + func with(_ addition: String?) -> String { + guard !self.isEmpty else { return addition ?? ""} + guard let addition, !addition.isEmpty else { return self } + return self + " - " + addition + } } -extension DataImportViewModel { - @MainActor mutating func performAction(_ buttonType: ButtonType) { - performAction(for: buttonType, dismiss: { assertionFailure("Unexpected dismiss") }) +private extension URL { + static let mockURL = URL(fileURLWithPath: "/Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/") + + static let testCSV = URL(fileURLWithPath: "/Users/Dax/Downloads/passwords.csv") + static let testHTML = URL(fileURLWithPath: "/Users/Dax/Downloads/bookmarks.html") + + static func profile(named name: String) -> URL { + return mockURL.appendingPathComponent(name) } } From bd08e8f980cbf774de5e0e3022cf5b1b71134055 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 17:53:05 +0600 Subject: [PATCH 62/83] fix linter issues --- .../DataImport/Logins/Chromium/ChromiumDataImporter.swift | 4 ++-- .../DataImport/Logins/Firefox/FirefoxDataImporter.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift index 9c6d15e93a..e9b514c274 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift @@ -74,7 +74,7 @@ internal class ChromiumDataImporter: DataImporter { let loginsSummary = try loginResult.flatMap { logins in do { return try .success(loginImporter.importLogins(logins) { count in - try updateProgress(.importingPasswords(numberOfPasswords: count, + try updateProgress(.importingPasswords(numberOfPasswords: count, fraction: dataTypeFraction * (0.5 + Double(count) / Double(logins.count)))) }) } catch is CancellationError { @@ -112,7 +112,7 @@ internal class ChromiumDataImporter: DataImporter { summary[.bookmarks] = bookmarksSummary.map { .init($0) } - try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, fraction: passwordsFraction + dataTypeFraction * 1.0)) } diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift index 4e994893db..3160979618 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift @@ -72,7 +72,7 @@ internal class FirefoxDataImporter: DataImporter { let loginsSummary = try loginResult.flatMap { logins in do { return try .success(loginImporter.importLogins(logins) { count in - try updateProgress(.importingPasswords(numberOfPasswords: count, + try updateProgress(.importingPasswords(numberOfPasswords: count, fraction: dataTypeFraction * (0.5 + 0.5 * Double(count) / Double(logins.count)))) }) } catch is CancellationError { From 3ee7d7074d26f074caa2657a3f9dd93a364bbd54 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 18:24:47 +0600 Subject: [PATCH 63/83] fix FileImportViewLocalizationTests CI assertions --- .../DataImport/FileImportViewLocalizationTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/UnitTests/DataImport/FileImportViewLocalizationTests.swift b/UnitTests/DataImport/FileImportViewLocalizationTests.swift index 54603180af..8c65ea0356 100644 --- a/UnitTests/DataImport/FileImportViewLocalizationTests.swift +++ b/UnitTests/DataImport/FileImportViewLocalizationTests.swift @@ -30,7 +30,7 @@ class FileImportViewLocalizationTests: XCTestCase { customAssertionFailure = nil } - func testLocalizedStringForEnglish() throws { + func testFileImportLocalizedStrings() throws { // collect reference "Base" localization escaped format arguments var referenceValues = [DataImport.Source: [DataImport.DataType: [String]]]() for locale in [.base] + Bundle.main.availableLocalizations().filter({ $0 != .base }) { @@ -43,7 +43,7 @@ class FileImportViewLocalizationTests: XCTestCase { // build instructions let items = fileImportInstructionsBuilder(source: source, dataType: dataType) { title in // button title should not be empty - XCTAssertFalse(title.isEmpty) + XCTAssertFalse(title.isEmpty, "\(locale).\(source.rawValue).\(dataType.rawValue).title") // button should be present in items e.fulfill() return AnyView(EmptyView()) @@ -89,11 +89,11 @@ class FileImportViewLocalizationTests: XCTestCase { "\(locale).\(source.rawValue).\(dataType.rawValue).strings") #if CI - customAssert = { _, _, _, _ in - XCTFail("\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assert") + customAssert = { condition, message, file, line in + XCTAssert(condition(), "\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assert: " + message(), file: file, line: line) } - customAssertionFailure = { _, _, _ in - XCTFail("\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assertionFailure") + customAssertionFailure = { message, file, line in + XCTFail("\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assertionFailure: " + message(), file: file, line: line) } #endif _=InstructionsView { From 73cd5a1216a7e3f5b401e0dcae3918bc6a23bdc8 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 18:40:00 +0600 Subject: [PATCH 64/83] fix failing test --- UnitTests/DataImport/DataImportViewModelTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift index 9871956a7e..ef4a027942 100644 --- a/UnitTests/DataImport/DataImportViewModelTests.swift +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -278,7 +278,9 @@ import XCTest } func testWhenImportSourceChanges_selectedDataTypesAreReset() { - model = DataImportViewModel(importSource: .safari) + setupModel(with: .safari, profiles: [BrowserProfile.test]) { _, _, _, _ in + ImporterMock(importableTypes: [.passwords, .bookmarks], keychainPasswordRequiredFor: [.passwords]) + } model.setDataType(.bookmarks, selected: false) model.setDataType(.passwords, selected: false) From e45280620abce1ecc92e16814305956f795ce0f5 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 21:21:19 +0600 Subject: [PATCH 65/83] =?UTF-8?q?Show=20generic=20summary=20header=20when?= =?UTF-8?q?=20there=E2=80=98s=20a=20failure=20in=20summary=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/DataImportSummaryViewModel.swift | 9 ++++----- .../DataImport/View/DataImportSummaryView.swift | 12 +++++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift index 53f5902268..66527cfe26 100644 --- a/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift @@ -39,7 +39,10 @@ struct DataImportSummaryViewModel { let dataTypes = dataTypes ?? Set(results.map(\.dataType)) assert(!dataTypes.isEmpty) - if dataTypes.count > 1 { + if dataTypes.count > 1 || dataTypes.contains(where: { dataType in + // always “results” if there‘s a failure + results.last(where: { $0.dataType == dataType })?.result.isSuccess == false + }) { self.summaryKind = .results } else { @@ -54,8 +57,4 @@ struct DataImportSummaryViewModel { } } - init(source: Source, summary: [DataType: DataImportResult]) { - self.init(source: source, results: summary.reduce(into: [], { $0.append(.init($1.key, $1.value)) }), dataTypes: Set(summary.keys)) - } - } diff --git a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift index 87f51a354c..08e19b02e7 100644 --- a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift +++ b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift @@ -126,12 +126,17 @@ private func failureImage() -> some View { .frame(width: 16, height: 16) } +#if DEBUG #Preview { VStack { HStack { - DataImportSummaryView(model: .init(source: .chrome, summary: [ - .bookmarks: .success(.init(successful: 123, duplicate: 456, failed: 7890)), - .passwords: .success(.init(successful: 123, duplicate: 456, failed: 7890)) + DataImportSummaryView(model: .init(source: .chrome, results: [ +// .init(.bookmarks, .success(.init(successful: 123, duplicate: 456, failed: 7890))), +// .init(.passwords, .success(.init(successful: 123, duplicate: 456, failed: 7890))), +// .init(.bookmarks, .failure(DataImportViewModel.TestImportError(action: .bookmarks, errorType: .dataCorrupted))), +// .init(.bookmarks, .failure(DataImportViewModel.TestImportError(action: .passwords, errorType: .keychainError))), + .init(.passwords, .failure(DataImportViewModel.TestImportError(action: .passwords, errorType: .keychainError))), + .init(.passwords, .failure(DataImportViewModel.TestImportError(action: .passwords, errorType: .keychainError))), ])) .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) Spacer() @@ -139,3 +144,4 @@ private func failureImage() -> some View { } .frame(width: 512) } +#endif From 252ef22feac97e9fe70fe74356777e5ad4b3690a Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 21:34:37 +0600 Subject: [PATCH 66/83] align file import instructions with numbers in circles by top --- DuckDuckGo/DataImport/View/FileImportView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index 06485d8b14..b515b27c49 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -736,7 +736,7 @@ struct InstructionsView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(instructions.indices, id: \.self) { i in - HStack(spacing: 8) { + HStack(alignment: .top, spacing: 8) { ForEach(instructions[i].indices, id: \.self) { j in switch instructions[i][j] { case .lineNumber(let number): @@ -744,6 +744,7 @@ struct InstructionsView: View { case .textItems(let textParts): Text(textParts) .makeSelectable() + .frame(minHeight: CircleNumberView.Constants.diameter) case .view(let view): view } @@ -794,12 +795,16 @@ private extension Text { struct CircleNumberView: View { + enum Constants { + static let diameter: CGFloat = 20 + } + let number: Int var body: some View { Circle() .fill(.globalBackground) - .frame(width: 20, height: 20) + .frame(width: Constants.diameter, height: Constants.diameter) .overlay( Text("\(number)") .foregroundColor(.onboardingActionButton) From 549b4d63e24648da89d85d0f00080ffdda43adcf Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 15 Dec 2023 22:30:24 +0600 Subject: [PATCH 67/83] add localization comment --- DuckDuckGo/DataImport/View/FileImportView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index b515b27c49..a529a7a8b5 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -486,8 +486,8 @@ struct FileImportView: View { HStack { Image(.info) Text(""" - You can find your version by selecting **\(source.importSourceName) → About 1Password** from the Menu Bar - """) + You can find your version by selecting **\(source.importSourceName) → About \(source.importSourceName)** from the Menu Bar + """, comment: "Instructions how to find an installed 1Password password manager app version. %1$@, %2$@ - app name (1Password)") Spacer() } .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) From fb60932c1ff66434acda3c67ff161682381206f8 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sun, 17 Dec 2023 15:50:25 +0600 Subject: [PATCH 68/83] fix SwiftUI crash on Big Sur --- .../SecureVault/View/EditableTextView.swift | 89 ++++++++++--------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/DuckDuckGo/SecureVault/View/EditableTextView.swift b/DuckDuckGo/SecureVault/View/EditableTextView.swift index 543e173887..774955e160 100644 --- a/DuckDuckGo/SecureVault/View/EditableTextView.swift +++ b/DuckDuckGo/SecureVault/View/EditableTextView.swift @@ -23,16 +23,28 @@ struct EditableTextView: NSViewRepresentable { @Binding var text: String - var isEditable: Bool = true - var font: NSFont? = .systemFont(ofSize: 14, weight: .regular) - var onEditingChanged: () -> Void = {} - var onCommit: () -> Void = {} - var onTextChange: (String) -> Void = { _ in } + var isEditable: Bool + var font: NSFont + var onEditingChanged: () -> Void + var onCommit: () -> Void + var onTextChange: (String) -> Void var maxLength: Int? var insets: NSSize? + init(text: Binding, isEditable: Bool = true, font: NSFont?, onEditingChanged: @escaping () -> Void = {}, onCommit: @escaping () -> Void = {}, onTextChange: @escaping (String) -> Void = { _ in }, maxLength: Int? = nil, insets: NSSize? = nil) { + + self._text = text + self.isEditable = isEditable + self.font = font ?? .systemFont(ofSize: 13, weight: .regular) + self.onEditingChanged = onEditingChanged + self.onCommit = onCommit + self.onTextChange = onTextChange + self.maxLength = maxLength + self.insets = insets + } + func makeCoordinator() -> Coordinator { - Coordinator(self) + return Coordinator(self) } func makeNSView(context: Context) -> CustomTextView { @@ -40,9 +52,9 @@ struct EditableTextView: NSViewRepresentable { text: text, isEditable: isEditable, font: font, - insets: insets + insets: insets, + delegate: context.coordinator ) - textView.delegate = context.coordinator return textView } @@ -74,15 +86,15 @@ extension EditableTextView { } func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } + guard let textView = notification.object as? NSTextView else { return } if let maxLength = parent.maxLength, textView.string.count > maxLength { textView.string = String(textView.string.prefix(maxLength)) } - self.parent.text = textView.string + if self.parent.text != textView.string { + self.parent.text = textView.string + } self.selectedRanges = textView.selectedRanges } @@ -94,30 +106,36 @@ extension EditableTextView { final class CustomTextView: NSView { - private let isEditable: Bool - private let font: NSFont? - private let insets: NSSize? weak var delegate: NSTextViewDelegate? var text: String { didSet { - textView.string = text + if textView.string != text { + textView.string = text + } } } var selectedRanges: [NSValue] = [] { didSet { - guard selectedRanges.count > 0 else { - return - } - + guard !selectedRanges.isEmpty else { return } textView.selectedRanges = selectedRanges } } - private lazy var scrollView: NSScrollView = { - let scrollView = NSScrollView() + let scrollView: NSScrollView + let textView: NSTextView + + // MARK: - Init + + init(text: String = "", isEditable: Bool, font: NSFont, insets: NSSize? = nil, delegate: NSTextViewDelegate? = nil, selectedRanges: [NSValue] = []) { + self.text = text + self.selectedRanges = selectedRanges + + self.delegate = delegate + + scrollView = NSScrollView() scrollView.drawsBackground = true scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true @@ -125,10 +143,6 @@ final class CustomTextView: NSView { scrollView.autoresizingMask = [.width, .height] scrollView.translatesAutoresizingMaskIntoConstraints = false - return scrollView - }() - - private lazy var textView: NSTextView = { let contentSize = scrollView.contentSize let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() @@ -139,33 +153,27 @@ final class CustomTextView: NSView { textContainer.containerSize = NSSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude) layoutManager.addTextContainer(textContainer) - let textView = NSTextView(frame: .zero, textContainer: textContainer) + textView = NSTextView(frame: .zero, textContainer: textContainer) textView.autoresizingMask = .width textView.backgroundColor = NSColor(named: "PWMEditingControlColor")! textView.delegate = self.delegate textView.drawsBackground = true - textView.font = self.font - textView.isEditable = self.isEditable + textView.font = font + textView.isEditable = isEditable textView.isHorizontallyResizable = false textView.isVerticallyResizable = true textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.minSize = NSSize(width: 0, height: contentSize.height) textView.textColor = NSColor.labelColor textView.allowsUndo = true + textView.string = text + if let insets { textView.textContainerInset = insets } - - return textView - }() - - // MARK: - Init - - init(text: String, isEditable: Bool, font: NSFont?, insets: NSSize?) { - self.font = font - self.isEditable = isEditable - self.text = text - self.insets = insets + if !selectedRanges.isEmpty { + textView.selectedRanges = selectedRanges + } super.init(frame: .zero) } @@ -197,4 +205,5 @@ final class CustomTextView: NSView { func setupTextView() { scrollView.documentView = textView } + } From 8064382843aa9acc5fb5f2a5f58136f0f2e76119 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sun, 17 Dec 2023 15:50:43 +0600 Subject: [PATCH 69/83] fix dialog dismiss on Big Sur --- .../Common/View/SwiftUI/ViewExtension.swift | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift b/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift index ce866c79f0..9c2197fe04 100644 --- a/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift +++ b/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift @@ -44,12 +44,16 @@ extension View { @available(macOS, obsoleted: 14.0, message: "This needs to be removed as it‘s no longer necessary.") @ViewBuilder func legacyOnDismiss(_ onDismiss: @escaping () -> Void) -> some View { - if #unavailable(macOS 14.0), let presentationModeKey = \EnvironmentValues.presentationMode as? WritableKeyPath { + if #available(macOS 14.0, *) { + self + + } else if let presentationModeKey = \EnvironmentValues.presentationMode as? WritableKeyPath { // hacky way to set the @Environment.presentationMode. // here we downcast a (non-writable) \.presentationMode KeyPath to a WritableKeyPath self.environment(presentationModeKey, Binding(onDismiss: onDismiss)) } else { - self + // macOS 11 compatibility: + self.environment(\.legacyDismiss, onDismiss) } } } @@ -83,11 +87,30 @@ struct DismissAction { dismiss() } } + +@available(macOS, obsoleted: 12.0, message: "This needs to be removed as it‘s no longer necessary.") +struct LegacyDismissAction: EnvironmentKey { + static var defaultValue: () -> Void { { } } +} + extension EnvironmentValues { @available(macOS, obsoleted: 12.0, message: "This extension needs to be removed as it‘s no longer necessary.") var dismiss: DismissAction { DismissAction { - presentationMode.wrappedValue.dismiss() + if \EnvironmentValues.presentationMode as? WritableKeyPath != nil { + presentationMode.wrappedValue.dismiss() + } else { + self[LegacyDismissAction.self]() + } + } + } + @available(macOS, obsoleted: 12.0, message: "This extension needs to be removed as it‘s no longer necessary.") + fileprivate var legacyDismiss: () -> Void { + get { + self[LegacyDismissAction.self] + } + set { + self[LegacyDismissAction.self] = newValue } } } From 5d5451bece9d280f53fac118125a3ea9831a29d3 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sun, 17 Dec 2023 15:52:36 +0600 Subject: [PATCH 70/83] Ship review styling and copy fixes --- DuckDuckGo/Common/Localizables/UserText.swift | 8 +- .../DataImport/View/DataImportErrorView.swift | 12 +- .../View/DataImportNoDataView.swift | 18 ++- .../View/DataImportSummaryView.swift | 4 +- .../View/DataImportTypePicker.swift | 7 +- .../DataImport/View/DataImportView.swift | 21 ++- .../DataImport/View/FileImportView.swift | 51 ++++--- DuckDuckGo/Localizable.xcstrings | 132 +++++++++++------- .../InstructionsFormatParserTests.swift | 4 +- 9 files changed, 162 insertions(+), 95 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 93db84e23c..1905a1e3cc 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -553,8 +553,8 @@ struct UserText { // MARK: - Login Import & Export - static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Passwords File", comment: "Title text for the CSV importer") - static let importBookmarksHTML = NSLocalizedString("import.bookmarks.html.title", value: "HTML Bookmarks File", comment: "Title text for the HTML Bookmarks importer") + static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Passwords File (for other browsers)", comment: "Title text for the CSV importer") + static let importBookmarksHTML = NSLocalizedString("import.bookmarks.html.title", value: "HTML Bookmarks File (for other browsers)", comment: "Title text for the HTML Bookmarks importer") static let importBookmarksSelectHTMLFile = NSLocalizedString("import.bookmarks.select-html-file", value: "Select Bookmarks HTML File…", comment: "Button text for selecting HTML Bookmarks file") static let importLoginsSelectCSVFile = NSLocalizedString("import.logins.select-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a CSV file") static func importLoginsSelectCSVFile(from source: DataImport.Source) -> String { @@ -564,7 +564,9 @@ struct UserText { static let importLoginsPasswords = NSLocalizedString("import.logins.passwords", value: "Passwords", comment: "Title text for the Passwords import option") static let initiateImport = NSLocalizedString("import.data.initiate", value: "Import", comment: "Button text for importing data") - static let skipImportFormat = NSLocalizedString("import.data.skip.format", value: "Skip %@", comment: "Button text to skip a kind of imported data (Bookmarks or Passwords - %@)") + static let skipBookmarksImport = NSLocalizedString("import.data.skip.bookmarks", value: "Skip bookmarks", comment: "Button text to skip bookmarks manual import") + static let skipPasswordsImport = NSLocalizedString("import.data.skip.passwords", value: "Skip passwords", comment: "Button text to skip bookmarks manual import") + static let skip = NSLocalizedString("import.data.skip", value: "Skip", comment: "Button text to skip an import step") static let done = NSLocalizedString("import.data.done", value: "Done", comment: "Button text for finishing the data import") static let manualImport = NSLocalizedString("import.data.manual", value: "Manual import…", comment: "Button text for initiating manual data import using a HTML or CSV file when automatic import has failed") diff --git a/DuckDuckGo/DataImport/View/DataImportErrorView.swift b/DuckDuckGo/DataImport/View/DataImportErrorView.swift index b730d4483b..f28f36d08c 100644 --- a/DuckDuckGo/DataImport/View/DataImportErrorView.swift +++ b/DuckDuckGo/DataImport/View/DataImportErrorView.swift @@ -26,9 +26,15 @@ struct DataImportErrorView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("We were unable to import \(dataType.displayName) directly from \(source.importSourceName).", - comment: "Message when data import fails from a browser. %1$@ - Bookmarks or Passwords; %2$@ - a browser name") - .bold() + switch dataType { + case .bookmarks: + Text("We were unable to import bookmarks directly from \(source.importSourceName).", + comment: "Message when data import fails from a browser. %@ - a browser name") + .bold() + case .passwords: + Text("We were unable to import passwords directly from \(source.importSourceName).", + comment: "Message when data import fails from a browser. %@ - a browser name") + } Text("Let’s try doing it manually. It won’t take long.", comment: "Suggestion to switch to a Manual File Data Import when data import fails.") diff --git a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift index e694842e22..84fb50a583 100644 --- a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift +++ b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift @@ -26,11 +26,21 @@ struct DataImportNoDataView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("We couldn‘t find any \(dataType.displayName)…", comment: "Data import error message: Bookmarks or Passwords (%@) weren‘t found.") - .bold() + switch dataType { + case .bookmarks: + Text("We couldn‘t find any bookmarks.", comment: "Data import error message: Bookmarks weren‘t found.") + .bold() - Text("You could try importing \(dataType.displayName) manually.", - comment: "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file.") + Text("Try importing bookmarks manually instead.", + comment: "Data import error subtitle: suggestion to import Bookmarks manually by selecting a CSV or HTML file.") + + case .passwords: + Text("We couldn‘t find any passwords.", comment: "Data import error message: Passwords weren‘t found.") + .bold() + + Text("Try importing passwords manually instead.", + comment: "Data import error subtitle: suggestion to import Passwords manually by selecting a CSV or HTML file.") + } } } diff --git a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift index 08e19b02e7..6ed0efab7c 100644 --- a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift +++ b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift @@ -88,7 +88,7 @@ struct DataImportSummaryView: View { case (.passwords, .failure): HStack { failureImage() - Text("Passwords import failed.", + Text("Password import failed.", comment: "Data import summary message of failed passwords import.") } @@ -103,7 +103,7 @@ struct DataImportSummaryView: View { if summary.failed > 0 { HStack { failureImage() - Text("Passwords import failed: ", + Text("Password import failed: ", comment: "Data import summary format of how many passwords (%lld) failed to import.") + Text(" " as String) + Text(String(summary.failed)).bold() diff --git a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift index 8c872af0f9..db6c2c9dd9 100644 --- a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift @@ -48,9 +48,10 @@ struct DataImportTypePicker: View { .disabled(!viewModel.importSource.supportedDataTypes.contains(dataType)) // subtitle - if !viewModel.importSource.supportedDataTypes.contains(dataType) { - Text("\(viewModel.importSource.importSourceName) does not support storing \(dataType.displayName)", - comment: "Data Import disabled checkbox message about a browser (%1$@) not supporting storing a data type (%2$@ - Bookmarks or Passwords)") + if case .passwords = dataType, + !viewModel.importSource.supportedDataTypes.contains(.passwords) { + Text("\(viewModel.importSource.importSourceName) does not support storing passwords", + comment: "Data Import disabled checkbox message about a browser (%@) not supporting storing passwords") .foregroundColor(Color(.disabledControlTextColor)) } } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index f83b844038..45682a2981 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -90,7 +90,9 @@ struct DataImportView: View { } #endif } - .font(.custom("SF Pro Text", size: 13)) + .font(Font(NSFont(name: "SF Pro Text", size: 13) + // fallback when SF Pro Text is missing + ?? NSFont.systemFont(ofSize: 13) as CTFont)) .frame(width: 512) .fixedSize() } @@ -103,7 +105,7 @@ struct DataImportView: View { // browser to import data from picker popup if case .feedback = model.screen {} else { - DataImportSourcePicker(selectedSource: model.importSource) { importSource in + DataImportSourcePicker(importSources: model.availableImportSources, selectedSource: model.importSource) { importSource in model.update(with: importSource) } .disabled(model.isImportSourcePickerDisabled) @@ -242,7 +244,11 @@ struct DataImportView: View { .padding(.leading, 20) Spacer() if case .normal = NSApp.runType { - Button("⛌" as String) { debugViewDisabled.toggle() } + Button { + debugViewDisabled.toggle() + } label: { + Image(.closeLarge) + } .buttonStyle(.borderless) .padding(.trailing, 20) } @@ -353,7 +359,14 @@ extension DataImportViewModel.ButtonType { case .initiateImport: UserText.initiateImport case .skip: - String(format: UserText.skipImportFormat, dataType?.displayName ?? "") + switch dataType { + case .bookmarks: + UserText.skipBookmarksImport + case .passwords: + UserText.skipPasswordsImport + case nil: + UserText.skip + } case .cancel: UserText.cancel case .back: diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index a529a7a8b5..150f5df20f 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -30,7 +30,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Open **%s** %d In a fresh tab, click %@ then **Google Password Manager → Settings** %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Google Chrome browser. @@ -50,7 +50,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Click %@ to open the application menu then click **Password Manager** %d Click %@ **at the top left** of the Password Manager and select **Settings** %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Brave browser. @@ -71,7 +71,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Open **%s** %d In a fresh tab, click %@ then **Password Manager → Settings** %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Chromium-based browsers. @@ -88,9 +88,9 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo case (.coccoc, .passwords): NSLocalizedString("import.csv.instructions.coccoc", value: """ %d Open **%s** - %d Type “_coccoc://settings/passwords_” into the Address field + %d Type “_coccoc://settings/passwords_” into the Address bar %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Chromium-based browsers. @@ -110,7 +110,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Use the Menu Bar to select **View → Show Password Manager** %d Select **Settings** %d Find “Export Passwords” and click **Download File** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Chromium-based browsers. @@ -125,7 +125,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo case (.vivaldi, .passwords): NSLocalizedString("import.csv.instructions.vivaldi", value: """ %d Open **%s** - %d Type “_chrome://settings/passwords_” into the Address field + %d Type “_chrome://settings/passwords_” into the Address bar %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** %d Save the file someplace you can find it (e.g., Desktop) %d %@ @@ -146,7 +146,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Open **%s** %d Use the Menu Bar to select **View → Show Password Manager** %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Chromium-based browsers. @@ -166,7 +166,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Click %@ to open the application menu then click **Passwords and cards** %d Click %@ then **Export passwords** %d Choose **To a text file (not secure)** and click **Export** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Yandex Browser. @@ -278,7 +278,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo NSLocalizedString("import.csv.instructions.safari", value: """ %d Open **Safari** %d Select **File → Export → Passwords** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Safari. @@ -292,7 +292,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo NSLocalizedString("import.html.instructions.safari", value: """ %d Open **Safari** %d Select **File → Export → Bookmarks** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Bookmarks exported as HTML from Safari. @@ -307,7 +307,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Open **%s** %d Click %@ to open the application menu then click **Passwords** %d Click %@ then **Export Logins…** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Firefox. @@ -347,7 +347,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Select **File → Export** from the Menu Bar and choose the account you want to export %d Enter your 1Password account password %d Select the File Format: **CSV (Logins and Passwords only)** - %d Click Export Data and save the file someplace you can find it (e.g. Desktop) + %d Click Export Data and save the file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from 1Password 8. @@ -361,11 +361,11 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo case (.onePassword7, .passwords): NSLocalizedString("import.csv.instructions.onePassword7", value: """ %d Open and unlock **%s** - %d Select the vault you want to Export (You cannot export from “All Vaults.”) + %d Select the vault you want to export (you can only export one vault at a time) %d Select **File → Export → All Items** from the Menu Bar %d Enter your 1Password master or account password %d Select the File Format: **iCloud Keychain (.csv)** - %d Save the passwords file someplace you can find it (e.g. Desktop) + %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from 1Password 7. @@ -381,8 +381,8 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Open and unlock **%s** %d Select **File → Export vault** from the Menu Bar %d Select the File Format: **.csv** - %d Enter your Bitwarden Master password - %d Click %@ and save the file someplace you can find it (e.g. Desktop) + %d Enter your Bitwarden master password + %d Click %@ and save the file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ Instructions to import Passwords as CSV from Bitwarden. @@ -485,9 +485,18 @@ struct FileImportView: View { if [.onePassword7, .onePassword8].contains(source) { HStack { Image(.info) - Text(""" - You can find your version by selecting **\(source.importSourceName) → About \(source.importSourceName)** from the Menu Bar - """, comment: "Instructions how to find an installed 1Password password manager app version. %1$@, %2$@ - app name (1Password)") + // markdown not supported on macOS 11 + InstructionsView { + NSLocalizedString("import.onePassword.app.version.info", value: """ + You can find your version by selecting **%s → About %s** from the Menu Bar. + """, comment: """ + Instructions how to find an installed 1Password password manager app version. + %1$s, %2$s - app name (1Password) + """) + source.importSourceName + source.importSourceName + } + Spacer() } .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) @@ -819,7 +828,7 @@ struct CircleNumberView: View { #Preview { HStack { - FileImportView(source: .yandex, dataType: .passwords, isButtonDisabled: false) + FileImportView(source: .onePassword8, dataType: .passwords, isButtonDisabled: false) .padding() .frame(width: 512 - 20) } diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index d6c9aa0557..f11b306d95 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -34,16 +34,8 @@ "**%lld** tracking attempts blocked" : { }, - "%@ does not support storing %@" : { - "comment" : "Data Import disabled checkbox message about a browser (%1$@) not supporting storing a data type (%2$@ - Bookmarks or Passwords)", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ does not support storing %2$@" - } - } - } + "%@ does not support storing passwords" : { + "comment" : "Data Import disabled checkbox message about a browser (%@) not supporting storing passwords" }, "%lld" : { @@ -3429,7 +3421,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "HTML Bookmarks File" + "value" : "HTML Bookmarks File (for other browsers)" } } } @@ -3525,7 +3517,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export vault** from the Menu Bar\n%4$d Select the File Format: **.csv**\n%5$d Enter your Bitwarden Master password\n%6$d Click %7$@ and save the file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export vault** from the Menu Bar\n%4$d Select the File Format: **.csv**\n%5$d Enter your Bitwarden master password\n%6$d Click %7$@ and save the file someplace you can find it (e.g., Desktop)\n%8$d %9$@" } } } @@ -3537,7 +3529,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Password Manager**\n%5$d Click %6$@ **at the top left** of the Password Manager and select **Settings**\n%7$d Find “Export Passwords” and click **Download File**\n%8$d Save the passwords file someplace you can find it (e.g. Desktop)\n%9$d %10$@" + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Password Manager**\n%5$d Click %6$@ **at the top left** of the Password Manager and select **Settings**\n%7$d Find “Export Passwords” and click **Download File**\n%8$d Save the passwords file someplace you can find it (e.g., Desktop)\n%9$d %10$@" } } } @@ -3549,7 +3541,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Google Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Google Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" } } } @@ -3561,7 +3553,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" } } } @@ -3573,7 +3565,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d Type “_coccoc://settings/passwords_” into the Address field\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + "value" : "%1$d Open **%2$s**\n%3$d Type “_coccoc://settings/passwords_” into the Address bar\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" } } } @@ -3585,7 +3577,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords**\n%5$d Click %6$@ then **Export Logins…**\n%7$d Save the passwords file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords**\n%5$d Click %6$@ then **Export Logins…**\n%7$d Save the passwords file someplace you can find it (e.g., Desktop)\n%8$d %9$@" } } } @@ -3621,7 +3613,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open and unlock **%2$s**\n%3$d Select the vault you want to Export (You cannot export from “All Vaults.”)\n%4$d Select **File → Export → All Items** from the Menu Bar\n%5$d Enter your 1Password master or account password\n%6$d Select the File Format: **iCloud Keychain (.csv)**\n%7$d Save the passwords file someplace you can find it (e.g. Desktop)\n%8$d %9$@" + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select the vault you want to export (you can only export one vault at a time)\n%4$d Select **File → Export → All Items** from the Menu Bar\n%5$d Enter your 1Password master or account password\n%6$d Select the File Format: **iCloud Keychain (.csv)**\n%7$d Save the passwords file someplace you can find it (e.g., Desktop)\n%8$d %9$@" } } } @@ -3633,7 +3625,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export** from the Menu Bar and choose the account you want to export\n%4$d Enter your 1Password account password\n%5$d Select the File Format: **CSV (Logins and Passwords only)**\n%6$d Click Export Data and save the file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export** from the Menu Bar and choose the account you want to export\n%4$d Enter your 1Password account password\n%5$d Select the File Format: **CSV (Logins and Passwords only)**\n%6$d Click Export Data and save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" } } } @@ -3645,7 +3637,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Select **Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Select **Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" } } } @@ -3657,7 +3649,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g. Desktop)\n%7$d %8$@" + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" } } } @@ -3669,7 +3661,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Passwords**\n%3$d Save the passwords file someplace you can find it (e.g. Desktop)\n%4$d %5$@" + "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Passwords**\n%3$d Save the passwords file someplace you can find it (e.g., Desktop)\n%4$d %5$@" } } } @@ -3681,7 +3673,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d Type “_chrome://settings/passwords_” into the Address field\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + "value" : "%1$d Open **%2$s**\n%3$d Type “_chrome://settings/passwords_” into the Address bar\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" } } } @@ -3693,7 +3685,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords and cards**\n%5$d Click %6$@ then **Export passwords**\n%7$d Choose **To a text file (not secure)** and click **Export**\n%8$d Save the passwords file someplace you can find it (e.g. Desktop)\n%9$d %10$@" + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords and cards**\n%5$d Click %6$@ then **Export passwords**\n%7$d Choose **To a text file (not secure)** and click **Export**\n%8$d Save the passwords file someplace you can find it (e.g., Desktop)\n%9$d %10$@" } } } @@ -3782,14 +3774,38 @@ } } }, - "import.data.skip.format" : { - "comment" : "Button text to skip a kind of imported data (Bookmarks or Passwords - %@)", + "import.data.skip" : { + "comment" : "Button text to skip an import step", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip" + } + } + } + }, + "import.data.skip.bookmarks" : { + "comment" : "Button text to skip bookmarks manual import", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip bookmarks" + } + } + } + }, + "import.data.skip.passwords" : { + "comment" : "Button text to skip bookmarks manual import", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Skip %@" + "value" : "Skip passwords" } } } @@ -3861,7 +3877,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Bookmarks**\n%3$d Save the passwords file someplace you can find it (e.g. Desktop)\n%4$d %5$@" + "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Bookmarks**\n%3$d Save the passwords file someplace you can find it (e.g., Desktop)\n%4$d %5$@" } } } @@ -3897,7 +3913,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "CSV Passwords File" + "value" : "CSV Passwords File (for other browsers)" } } } @@ -3938,6 +3954,18 @@ } } }, + "import.onePassword.app.version.info" : { + "comment" : "Instructions how to find an installed 1Password password manager app version.\n%1$s, %2$s - app name (1Password)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You can find your version by selecting **%1$s → About %2$s** from the Menu Bar." + } + } + } + }, "import.passwords.indefinite.progress.text" : { "comment" : "Operation progress info message about indefinite number of passwords being imported", "extractionState" : "extracted_with_value", @@ -5890,6 +5918,12 @@ "Password import complete. You can now delete the saved passwords file." : { "comment" : "message about Passwords Data Import completion" }, + "Password import failed: " : { + "comment" : "Data import summary format of how many passwords (%lld) failed to import." + }, + "Password import failed." : { + "comment" : "Data import summary message of failed passwords import." + }, "password.manager" : { "comment" : "Section header", "extractionState" : "extracted_with_value", @@ -5902,12 +5936,6 @@ } } }, - "Passwords import failed: " : { - "comment" : "Data import summary format of how many passwords (%lld) failed to import." - }, - "Passwords import failed." : { - "comment" : "Data import summary message of failed passwords import." - }, "Passwords:" : { "comment" : "Data import summary format of how many passwords (%lld) were successfully imported." }, @@ -8524,6 +8552,12 @@ "Transformations" : { "comment" : "Main Menu Edit item" }, + "Try importing bookmarks manually instead." : { + "comment" : "Data import error subtitle: suggestion to import Bookmarks manually by selecting a CSV or HTML file." + }, + "Try importing passwords manually instead." : { + "comment" : "Data import error subtitle: suggestion to import Passwords manually by selecting a CSV or HTML file." + }, "Undo" : { "comment" : "Main Menu Edit item" }, @@ -8973,19 +9007,17 @@ } } }, - "We couldn‘t find any %@…" : { - "comment" : "Data import error message: Bookmarks or Passwords (%@) weren‘t found." + "We couldn‘t find any bookmarks." : { + "comment" : "Data import error message: Bookmarks weren‘t found." }, - "We were unable to import %@ directly from %@." : { - "comment" : "Message when data import fails from a browser. %1$@ - Bookmarks or Passwords; %2$@ - a browser name", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "We were unable to import %1$@ directly from %2$@." - } - } - } + "We couldn‘t find any passwords." : { + "comment" : "Data import error message: Passwords weren‘t found." + }, + "We were unable to import bookmarks directly from %@." : { + "comment" : "Message when data import fails from a browser. %@ - a browser name" + }, + "We were unable to import passwords directly from %@." : { + "comment" : "Message when data import fails from a browser. %@ - a browser name" }, "web.tracking.protection.explenation" : { "comment" : "feature explanation in settings", @@ -9014,12 +9046,6 @@ "Window" : { "comment" : "Main Menu " }, - "You can find your version by selecting **%@ → About 1Password** from the Menu Bar" : { - - }, - "You could try importing %@ manually." : { - "comment" : "Data import error subtitle: suggestion to import Bookmarks or Passwords (%@) manually by selecting a CSV or HTML file." - }, "You must be eligible to use this service." : { }, diff --git a/UnitTests/DataImport/InstructionsFormatParserTests.swift b/UnitTests/DataImport/InstructionsFormatParserTests.swift index 7ea0c24e0c..2b0bc5ca62 100644 --- a/UnitTests/DataImport/InstructionsFormatParserTests.swift +++ b/UnitTests/DataImport/InstructionsFormatParserTests.swift @@ -105,7 +105,7 @@ final class InstructionsFormatParserTests: XCTestCase { %d Open **%s** %d In a fresh tab, click %@ then **Google __Password__ Manager → Settings ** %d Find “Export _Passwords_” and click **Download File** - %d __Save the* passwords **file** someplace__ you can_find it (e.g. Desktop) + %d __Save the* passwords **file** someplace__ you can_find it (e.g., Desktop) %d %@ """ let parsed = try InstructionsFormatParser().parse(format: format) @@ -113,7 +113,7 @@ final class InstructionsFormatParserTests: XCTestCase { XCTAssertEqual(parsed[safe: 0], [.number(argIndex: 1), .text("Open "), .string(argIndex: 2, bold: true)]) XCTAssertEqual(parsed[safe: 1], [.number(argIndex: 3), .text("In a fresh tab, click "), .object(argIndex: 4), .text(" then "), .text("Google ", bold: true), .text("Password", bold: true, italic: true), .text(" Manager → Settings ", bold: true)]) XCTAssertEqual(parsed[safe: 2], [.number(argIndex: 5), .text("Find “Export "), .text("Passwords", italic: true), .text("” and click "), .text("Download File", bold: true)]) - XCTAssertEqual(parsed[safe: 3], [.number(argIndex: 6), .text("Save the* passwords ", italic: true), .text("file", bold: true, italic: true), .text(" someplace", italic: true), .text(" you can_find it (e.g. Desktop)")]) + XCTAssertEqual(parsed[safe: 3], [.number(argIndex: 6), .text("Save the* passwords ", italic: true), .text("file", bold: true, italic: true), .text(" someplace", italic: true), .text(" you can_find it (e.g., Desktop)")]) XCTAssertEqual(parsed[safe: 4], [.number(argIndex: 7), .object(argIndex: 8)]) XCTAssertNil(parsed[safe: 5]) } From 4e7fb31e978040ee96d9ca1f91c449a5a8392f2d Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sun, 17 Dec 2023 15:52:53 +0600 Subject: [PATCH 71/83] minor fixes --- .../DataImport/Model/DataImportViewModel.swift | 8 +++++--- .../DataImport/View/DataImportSourcePicker.swift | 7 ++++--- DuckDuckGo/DataImport/View/DataImportView.swift | 13 +++++++------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index bd0da1a6b4..34c5dcc9cb 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -32,6 +32,7 @@ struct DataImportViewModel { @UserDefaultsWrapper(key: .homePageContinueSetUpImport, defaultValue: nil) var successfulImportHappened: Bool? + let availableImportSources: [DataImport.Source] /// Browser to import data from let importSource: Source /// BrowserProfileList loader (factory method) - used @@ -128,7 +129,7 @@ struct DataImportViewModel { init(importSource: Source? = nil, screen: Screen? = nil, - availableImportSources: @autoclosure () -> Set = Set(ThirdPartyBrowser.installedBrowsers.map(\.importSource)), + availableImportSources: [DataImport.Source] = ThirdPartyBrowser.installedBrowsers.map(\.importSource), preferredImportSources: [Source] = [.chrome, .firefox, .safari], summary: [DataTypeImportResult] = [], loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() }, @@ -137,7 +138,7 @@ struct DataImportViewModel { openPanelCallback: @escaping @MainActor (DataType) -> URL? = Self.openPanelCallback, reportSenderFactory: @escaping ReportSenderFactory = { FeedbackSender().sendDataImportReport }) { - lazy var availableImportSources = availableImportSources() + self.availableImportSources = availableImportSources let importSource = importSource ?? preferredImportSources.first(where: { availableImportSources.contains($0) }) ?? .csv self.importSource = importSource @@ -459,7 +460,7 @@ extension DataImportViewModel { for dataType in (currentDataType.map { DataType.dataTypes(after: $0) } ?? DataType.allCases[0...]) where selectedDataTypes.contains(dataType) { // if some of selected data types failed to import or not imported yet switch summary.last(where: { $0.dataType == dataType })?.result { - case .success(let summary) where summary.successful == 0 && summary.duplicate == 0 && summary.failed == 0: + case .success(let summary) where summary.isEmpty: return .fileImport(dataType: dataType) case .failure(let error) where error.errorType == .noData: return .fileImport(dataType: dataType) @@ -680,6 +681,7 @@ extension DataImportViewModel { } } + log("dismiss") dismiss() if case .xcPreviews = NSApp.runType { self.update(with: importSource) // reset diff --git a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift index b2c2c03c08..3f94ffe2a0 100644 --- a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift @@ -29,9 +29,10 @@ struct DataImportSourcePicker: View { viewModel.importSources } - init(selectedSource: DataImport.Source, + init(importSources: [DataImport.Source], + selectedSource: DataImport.Source, onSelectedSourceChanged: @escaping (DataImport.Source) -> Void) { - self.viewModel = DataImportSourceViewModel(selectedSource: selectedSource) + self.viewModel = DataImportSourceViewModel(importSources: importSources, selectedSource: selectedSource) self.onSelectedSourceChanged = onSelectedSourceChanged } @@ -61,7 +62,7 @@ struct DataImportSourcePicker: View { } #Preview { - DataImportSourcePicker(selectedSource: .csv) { + DataImportSourcePicker(importSources: DataImport.Source.allCases, selectedSource: .csv) { print("selection:", $0) } .padding() diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 45682a2981..9480a224c4 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -197,15 +197,16 @@ struct DataImportView: View { HStack(spacing: 8) { Spacer() - ForEach(model.buttons, id: \.self) { button in + ForEach(model.buttons.indices, id: \.self) { idx in Button { - model.performAction(for: button, dismiss: dismiss.callAsFunction) + model.performAction(for: model.buttons[idx], + dismiss: dismiss.callAsFunction) } label: { - Text(button.title(dataType: model.screen.fileImportDataType)) + Text(model.buttons[idx].title(dataType: model.screen.fileImportDataType)) .frame(minWidth: 80 - 16 - 1) } - .keyboardShortcut(button.shortcut) - .disabled(button.isDisabled) + .keyboardShortcut(model.buttons[idx].shortcut) + .disabled(model.buttons[idx].isDisabled) } } } @@ -500,7 +501,7 @@ extension DataImportViewModel.ButtonType { } } - let viewModel = DataImportViewModel(importSource: .bookmarksHTML) { browser in + let viewModel = DataImportViewModel(importSource: .bookmarksHTML, availableImportSources: DataImport.Source.allCases) { browser in guard case .chrome = browser else { print("empty profiles") return .init(browser: browser, profiles: []) From 5d054ca81920530862b07aa4d67801266be1ebcf Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 18 Dec 2023 12:40:36 +0600 Subject: [PATCH 72/83] fix sandboxed build --- .../Common/View/SwiftUI/ViewExtension.swift | 2 + DuckDuckGo/Localizable.xcstrings | 150 ++++++++++++++++-- 2 files changed, 137 insertions(+), 15 deletions(-) diff --git a/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift b/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift index 9c2197fe04..0869548b97 100644 --- a/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift +++ b/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift @@ -52,8 +52,10 @@ extension View { // here we downcast a (non-writable) \.presentationMode KeyPath to a WritableKeyPath self.environment(presentationModeKey, Binding(onDismiss: onDismiss)) } else { +#if !APPSTORE // macOS 11 compatibility: self.environment(\.legacyDismiss, onDismiss) +#endif } } } diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index f11b306d95..ec3c2cf4a5 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -599,7 +599,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Lock Autofill after computer is idle for" + "value" : "Lock autofill after computer is idle for" } } } @@ -635,7 +635,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Never lock Autofill" + "value" : "Never lock autofill" } } } @@ -731,7 +731,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Login saved for %@" + "value" : "Password saved for %@" } } } @@ -5046,6 +5046,18 @@ } } }, + "network.protection.vpn.location.country.item.formatted.cities.count" : { + "comment" : "Subtitle of countries item when there are multiple cities, example : ", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%d cities" + } + } + } + }, "New Tab" : { "comment" : "Main Menu File item" }, @@ -5886,7 +5898,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Logins" + "value" : "Passwords" } } } @@ -6606,7 +6618,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "If your logins are saved in another browser, you can import them into DuckDuckGo." + "value" : "If your passwords are saved in another browser, you can import them into DuckDuckGo." } } } @@ -6618,7 +6630,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "No Logins or Payment Methods saved yet" + "value" : "No passwords or credit cards saved yet" } } } @@ -6642,7 +6654,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "No Logins" + "value" : "No passwords" } } } @@ -6713,7 +6725,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Your Autofill info will remain unlocked until your computer is idle for %@." + "value" : "Your autofill info will remain unlocked until your computer is idle for %@." } } } @@ -6749,7 +6761,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "unlock access to your Autofill info" + "value" : "unlock access to your autofill info" } } } @@ -6761,7 +6773,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "change your Autofill info access settings" + "value" : "change your autofill info access settings" } } } @@ -6785,7 +6797,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "unlock access to your Autofill info" + "value" : "unlock access to your autofill info" } } } @@ -6941,7 +6953,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Login" + "value" : "Password" } } } @@ -7157,7 +7169,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Save Login?" + "value" : "Save password?" } } } @@ -7169,7 +7181,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "New Login Saved" + "value" : "New Password Saved" } } } @@ -8911,6 +8923,114 @@ } } }, + "vpn.location.change.button.title" : { + "comment" : "Title of the VPN location preference change button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change..." + } + } + } + }, + "vpn.location.custom.section.title" : { + "comment" : "Title of the VPN location list cancel button\n Title of the VPN location list custom section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Custom" + } + } + } + }, + "vpn.location.description.nearest" : { + "comment" : "Nearest city setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Nearest" + } + } + } + }, + "vpn.location.description.nearest.available" : { + "comment" : "Nearest available location setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Nearest Available" + } + } + } + }, + "vpn.location.list.title" : { + "comment" : "Title of the VPN location list screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN Location" + } + } + } + }, + "vpn.location.nearest.available.title" : { + "comment" : "Subtitle underneath the nearest available vpn location preference text.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically connect to the nearest server we can find." + } + } + } + }, + "vpn.location.recommended.section.title" : { + "comment" : "Title of the VPN location list recommended section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recommended" + } + } + } + }, + "vpn.location.submit.button.title" : { + "comment" : "Title of the VPN location list submit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit" + } + } + } + }, + "vpn.location.title" : { + "comment" : "Location section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location" + } + } + } + }, "vpn.notifications.settings.title" : { "comment" : "Notifications section title in VPN settings", "extractionState" : "extracted_with_value", @@ -9072,4 +9192,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 36b47df2a961f5fad781335eaafccb7e482146b9 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 18 Dec 2023 21:52:17 +0600 Subject: [PATCH 73/83] fix missing import sources --- .../DataImport/Model/DataImportViewModel.swift | 2 +- DuckDuckGo/DataImport/ThirdPartyBrowser.swift | 4 ---- .../TestBookmarksData/bookmarks_chrome2.html | 0 .../TestBookmarksData/bookmarks_firefox2.html | 13 +++++++++++++ .../TestBookmarksData/bookmarks_netscape.html | 0 .../TestBookmarksData/bookmarks_safari_tp.html | 0 6 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome2.html create mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html create mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_netscape.html create mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari_tp.html diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 34c5dcc9cb..098c4cb492 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -129,7 +129,7 @@ struct DataImportViewModel { init(importSource: Source? = nil, screen: Screen? = nil, - availableImportSources: [DataImport.Source] = ThirdPartyBrowser.installedBrowsers.map(\.importSource), + availableImportSources: [DataImport.Source] = Source.allCases.filter { $0.canImportData }, preferredImportSources: [Source] = [.chrome, .firefox, .safari], summary: [DataTypeImportResult] = [], loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() }, diff --git a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift index 31eaeaa7a9..3eae669134 100644 --- a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift +++ b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift @@ -49,10 +49,6 @@ enum ThirdPartyBrowser: CaseIterable { case onePassword7 case onePassword8 - static var installedBrowsers: [ThirdPartyBrowser] { - return allCases.filter(\.isInstalled) - } - // swiftlint:disable:next cyclomatic_complexity static func browser(for source: DataImport.Source) -> ThirdPartyBrowser? { switch source { diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome2.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome2.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html new file mode 100644 index 0000000000..5edc0e29da --- /dev/null +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html @@ -0,0 +1,13 @@ +
+

Folder Name 1

+
+
Help and Tutorials +
Customize Firefox +
+ +

Folder Name B

+
+
Get Involved +
About Us +
+
diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_netscape.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_netscape.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari_tp.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari_tp.html new file mode 100644 index 0000000000..e69de29bb2 From 22d6d62c7dc3d698c1925e48805e2eb281797d16 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 20 Dec 2023 12:10:52 +0600 Subject: [PATCH 74/83] minor styling fix --- DuckDuckGo/DataImport/View/DataImportErrorView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DuckDuckGo/DataImport/View/DataImportErrorView.swift b/DuckDuckGo/DataImport/View/DataImportErrorView.swift index f28f36d08c..96740b2cc9 100644 --- a/DuckDuckGo/DataImport/View/DataImportErrorView.swift +++ b/DuckDuckGo/DataImport/View/DataImportErrorView.swift @@ -34,6 +34,7 @@ struct DataImportErrorView: View { case .passwords: Text("We were unable to import passwords directly from \(source.importSourceName).", comment: "Message when data import fails from a browser. %@ - a browser name") + .bold() } Text("Let’s try doing it manually. It won’t take long.", From d5125d170ed079dae1b5b73a33e2cbee3753a54f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 20 Dec 2023 12:14:08 +0600 Subject: [PATCH 75/83] add missing import --- DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift index 65fa251e98..4094df74ab 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import AppKit import Foundation import CommonCrypto import GRDB From 8b1a452930951dca07d591a3d07af6297399bb03 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 20 Dec 2023 13:09:14 +0600 Subject: [PATCH 76/83] fix support of Chrome Dev/Beta/Canary builds --- DuckDuckGo/DataImport/DataImport.swift | 9 +++- DuckDuckGo/DataImport/ThirdPartyBrowser.swift | 52 +++++++++++-------- .../View/DataImportProfilePicker.swift | 21 ++++++-- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 5d56c60b02..0c92606062 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -338,7 +338,14 @@ enum DataImport { } static func < (lhs: DataImport.BrowserProfile, rhs: DataImport.BrowserProfile) -> Bool { - return lhs.profileName.localizedCompare(rhs.profileName) == .orderedAscending + // first sort by profiles folder name if multiple profiles folders are present (Chrome, Chrome Canary…) + let profilesDirName1 = lhs.profileURL.deletingLastPathComponent().lastPathComponent + let profilesDirName2 = rhs.profileURL.deletingLastPathComponent().lastPathComponent + if profilesDirName1 == profilesDirName2 { + return lhs.profileName.localizedCompare(rhs.profileName) == .orderedAscending + } else { + return profilesDirName1.localizedCompare(profilesDirName2) == .orderedAscending + } } static func == (lhs: DataImport.BrowserProfile, rhs: DataImport.BrowserProfile) -> Bool { diff --git a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift index 3eae669134..68539c3137 100644 --- a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift +++ b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift @@ -179,11 +179,14 @@ enum ThirdPartyBrowser: CaseIterable { func browserProfiles(applicationSupportURL: URL? = nil) -> DataImport.BrowserProfileList { var potentialProfileURLs: [URL] { - guard let profilePath = profilesDirectory(applicationSupportURL: applicationSupportURL) else { return [] } - let potentialProfileURLs = (try? FileManager.default.contentsOfDirectory(at: profilePath, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles]).filter(\.hasDirectoryPath)) ?? [] - return potentialProfileURLs + [profilePath] + let fm = FileManager() + let profilesDirectories = self.profilesDirectories(applicationSupportURL: applicationSupportURL) + return profilesDirectories.reduce(into: []) { result, profilesDir in + result.append(contentsOf: (try? fm.contentsOfDirectory(at: profilesDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + .filter(\.hasDirectoryPath)) ?? []) + } + profilesDirectories } let profiles: [DataImport.BrowserProfile] @@ -191,7 +194,7 @@ enum ThirdPartyBrowser: CaseIterable { case .safari, .safariTechnologyPreview: // Safari is an exception, as it may need permissions granted before being able to read the contents of the profile path. To be safe, // return the profile anyway and check the file system permissions when preparing to import. - guard let profileURL = profilesDirectory(applicationSupportURL: applicationSupportURL) else { + guard let profileURL = profilesDirectories(applicationSupportURL: applicationSupportURL).first else { assertionFailure("Unexpected nil profileURL for Safari") profiles = [] break @@ -239,23 +242,28 @@ enum ThirdPartyBrowser: CaseIterable { // Returns the URL to the profiles for a given browser. This directory will contain a list of directories, each representing a profile. // swiftlint:disable:next cyclomatic_complexity - func profilesDirectory(applicationSupportURL: URL? = nil) -> URL? { + func profilesDirectories(applicationSupportURL: URL? = nil) -> [URL] { let applicationSupportURL = applicationSupportURL ?? URL.nonSandboxApplicationSupportDirectoryURL - switch self { - case .brave: return applicationSupportURL.appendingPathComponent("BraveSoftware/Brave-Browser/") - case .chrome: return applicationSupportURL.appendingPathComponent("Google/Chrome/") - case .chromium: return applicationSupportURL.appendingPathComponent("Chromium/") - case .coccoc: return applicationSupportURL.appendingPathComponent("Coccoc/") - case .edge: return applicationSupportURL.appendingPathComponent("Microsoft Edge/") - case .firefox: return applicationSupportURL.appendingPathComponent("Firefox/Profiles/") - case .opera: return applicationSupportURL.appendingPathComponent("com.operasoftware.Opera/") - case .operaGX: return applicationSupportURL.appendingPathComponent("com.operasoftware.OperaGX/") - case .safari: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("Safari/") - case .safariTechnologyPreview: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("SafariTechnologyPreview/") - case .tor: return applicationSupportURL.appendingPathComponent("TorBrowser-Data/Browser/") - case .vivaldi: return applicationSupportURL.appendingPathComponent("Vivaldi/") - case .yandex: return applicationSupportURL.appendingPathComponent("Yandex/YandexBrowser/") - case .bitwarden, .lastPass, .onePassword7, .onePassword8: return nil + return switch self { + case .brave: [applicationSupportURL.appendingPathComponent("BraveSoftware/Brave-Browser/")] + case .chrome: [ + applicationSupportURL.appendingPathComponent("Google/Chrome/"), + applicationSupportURL.appendingPathComponent("Google/Chrome Beta/"), + applicationSupportURL.appendingPathComponent("Google/Chrome Dev/"), + applicationSupportURL.appendingPathComponent("Google/Chrome Canary/"), + ] + case .chromium: [applicationSupportURL.appendingPathComponent("Chromium/")] + case .coccoc: [applicationSupportURL.appendingPathComponent("Coccoc/")] + case .edge: [applicationSupportURL.appendingPathComponent("Microsoft Edge/")] + case .firefox: [applicationSupportURL.appendingPathComponent("Firefox/Profiles/")] + case .opera: [applicationSupportURL.appendingPathComponent("com.operasoftware.Opera/")] + case .operaGX: [applicationSupportURL.appendingPathComponent("com.operasoftware.OperaGX/")] + case .safari: [URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("Safari/")] + case .safariTechnologyPreview: [URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("SafariTechnologyPreview/")] + case .tor: [applicationSupportURL.appendingPathComponent("TorBrowser-Data/Browser/")] + case .vivaldi: [applicationSupportURL.appendingPathComponent("Vivaldi/")] + case .yandex: [applicationSupportURL.appendingPathComponent("Yandex/YandexBrowser/")] + case .bitwarden, .lastPass, .onePassword7, .onePassword8: [] } } diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index 58da32140b..f25e5725a9 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -22,10 +22,14 @@ struct DataImportProfilePicker: View { private let profiles: [DataImport.BrowserProfile] @Binding private var selectedProfile: DataImport.BrowserProfile? + private let shouldDisplayFolderName: Bool init(profileList: DataImport.BrowserProfileList?, selectedProfile: Binding) { self.profiles = profileList?.validImportableProfiles ?? [] self._selectedProfile = selectedProfile + shouldDisplayFolderName = Set(self.profiles.map { + $0.profileURL.deletingLastPathComponent() + }).count > 1 } var body: some View { @@ -39,7 +43,16 @@ struct DataImportProfilePicker: View { selectedProfile = profiles[safe: $0] }) { ForEach(profiles.indices, id: \.self) { idx in - Text(profiles[idx].profileName) + // display profiles folder name if multiple profiles folders are present (Chrome, Chrome Canary…) + if shouldDisplayFolderName { + Text(profiles[idx].profileName + " ") + + Text(profiles[idx].profileURL + .deletingLastPathComponent().lastPathComponent) + .font(.system(size: 10)) + .fontWeight(.light) + } else { + Text(profiles[idx].profileName) + } } } label: {} .pickerStyle(.menu) @@ -52,11 +65,11 @@ struct DataImportProfilePicker: View { #Preview { DataImportProfilePicker(profileList: .init(browser: .chrome, profiles: [ .init(browser: .chrome, - profileURL: URL(fileURLWithPath: "/test/Default Profile")), + profileURL: URL(fileURLWithPath: "/Chrome/Default Profile")), .init(browser: .chrome, - profileURL: URL(fileURLWithPath: "/test/Profile 1")), + profileURL: URL(fileURLWithPath: "/Chrome Dev/Profile 1")), .init(browser: .chrome, - profileURL: URL(fileURLWithPath: "/test/Profile 2")), + profileURL: URL(fileURLWithPath: "/Chrome Canary/Profile 2")), ], validateProfileData: { _ in { .init(logins: .available, bookmarks: .available) } }), selectedProfile: Binding { .init(browser: .chrome, profileURL: URL(fileURLWithPath: "/test/Profile 1")) From f588e0fb3b23d11a5461fc79b789c8d2d8ff338f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 20 Dec 2023 14:19:55 +0600 Subject: [PATCH 77/83] fix safari get read permission assertion --- DuckDuckGo/DataImport/Model/DataImportViewModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 098c4cb492..66600d44ec 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -75,6 +75,10 @@ struct DataImportViewModel { if case .fileImport = self { true } else { false } } + var isGetReadPermission: Bool { + if case .getReadPermission = self { true } else { false } + } + var fileImportDataType: DataType? { switch self { case .fileImport(dataType: let dataType, summary: _): @@ -166,7 +170,7 @@ struct DataImportViewModel { assertionFailure("URL not provided") return } - assert(actionButton == .initiateImport(disabled: false) || screen.fileImportDataType != nil) + assert(actionButton == .initiateImport(disabled: false) || screen.fileImportDataType != nil || screen.isGetReadPermission) // are we handling file import or browser selected data types import? let dataType: DataType? = self.screen.fileImportDataType From 26b0c25d329262d39ce9a960d67398e27395d0f5 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 21 Dec 2023 18:28:52 +0600 Subject: [PATCH 78/83] fix feedback view styling, add focus ring --- .../View/DataImportProfilePicker.swift | 2 +- .../DataImport/View/DataImportView.swift | 4 +- .../DataImport/View/FileImportView.swift | 2 +- .../DataImport/View/ReportFeedbackView.swift | 83 ++++---- .../SecureVault/View/EditableTextView.swift | 187 +++++++++++------- 5 files changed, 173 insertions(+), 105 deletions(-) diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift index f25e5725a9..5f3ac8e1dc 100644 --- a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -78,5 +78,5 @@ struct DataImportProfilePicker: View { }) .padding() .frame(width: 512) - .font(.custom("SF Pro Text", size: 13)) + .font(.system(size: 13)) } diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index 9480a224c4..4cb60196b9 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -90,9 +90,7 @@ struct DataImportView: View { } #endif } - .font(Font(NSFont(name: "SF Pro Text", size: 13) - // fallback when SF Pro Text is missing - ?? NSFont.systemFont(ofSize: 13) as CTFont)) + .font(.system(size: 13)) .frame(width: 512) .fixedSize() } diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index 150f5df20f..9db57cec9e 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -832,6 +832,6 @@ struct CircleNumberView: View { .padding() .frame(width: 512 - 20) } - .font(.custom("SF Pro Text", size: 13)) + .font(.system(size: 13)) .background(Color.white) } diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift index 491a8e77aa..577b779cc6 100644 --- a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -57,23 +57,31 @@ struct ReportFeedbackView: View { } .padding(.bottom, 24) - ZStack(alignment: .top) { - EditableTextView(text: $model.text, - font: NSFont(name: "SF Pro Text", size: 13), - insets: NSSize(width: 11, height: 11)) - .cornerRadius(6) - .frame(height: 114) - .shadow(radius: 1, x: 0, y: 1) - - if model.text.isEmpty { - HStack { + EditableTextView(text: $model.text, + font: .systemFont(ofSize: 13), + insets: NSSize(width: 7, height: 12), + cornerRadius: 6, + backgroundColor: .textBackgroundColor, + textColor: .textColor, + focusRingType: .exterior, + isFocusedOnAppear: true) + .frame(height: 114) + .shadow(color: Color.addressBarShadow, radius: 1, x: 0, y: 1) + .overlay( + VStack(alignment: .leading) { + HStack(alignment: .top) { Text("Add any details that you think may help us fix the problem", comment: "Data import failure Report dialog suggestion to provide a comments with extra details helping to identify the data import problem.") - .foregroundColor(Color(.placeholderTextColor)) + .foregroundColor(Color(.placeholderTextColor)) + .padding(.leading, 11) Spacer() - }.padding(EdgeInsets(top: 11, leading: 11, bottom: 0, trailing: 11)) + } + .padding(.top, 11) + Spacer() } - } + .visibility(model.text.isEmpty ? .visible : .gone) + .allowsHitTesting(false) + ) } } @@ -108,31 +116,38 @@ private struct InfoItemView: View { } -#Preview { +#Preview { { - ReportFeedbackView(model: .constant(.init(importSource: .safari, importSourceVersion: UserAgent.safariVersion, error: { - enum ImportError: DataImportError { - enum OperationType: Int { - case imp - } + struct PreviewView: View { + @State var model = DataImportReportModel(importSource: .safari, importSourceVersion: UserAgent.safariVersion, error: { + enum ImportError: DataImportError { + enum OperationType: Int { + case imp + } - var type: OperationType { .imp } - var action: DataImportAction { .generic } - var underlyingError: Error? { - if case .err(let err) = self { - return err + var type: OperationType { .imp } + var action: DataImportAction { .generic } + var underlyingError: Error? { + if case .err(let err) = self { + return err + } + return nil } - return nil - } - static var errorDomain: String { "ReportFeedbackPreviewError" } - var errorType: DataImport.ErrorType { .noData } + static var errorDomain: String { "ReportFeedbackPreviewError" } + var errorType: DataImport.ErrorType { .noData } - case err(Error) + case err(Error) + } + return ImportError.err(CocoaError(.fileReadUnknown)) + }(), retryNumber: 1) + + var body: some View { + ReportFeedbackView(model: $model) + .frame(width: 512 - 20) + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) } - return ImportError.err(CocoaError(.fileReadUnknown)) - }(), retryNumber: 1))) - .frame(width: 512 - 20) - .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + } + return PreviewView() -} +} ()} diff --git a/DuckDuckGo/SecureVault/View/EditableTextView.swift b/DuckDuckGo/SecureVault/View/EditableTextView.swift index 774955e160..d5ccbcde34 100644 --- a/DuckDuckGo/SecureVault/View/EditableTextView.swift +++ b/DuckDuckGo/SecureVault/View/EditableTextView.swift @@ -16,32 +16,24 @@ // limitations under the License. // +import AppKit import Foundation import SwiftUI struct EditableTextView: NSViewRepresentable { + var isEditable: Bool = true + @Binding var text: String - var isEditable: Bool - var font: NSFont - var onEditingChanged: () -> Void - var onCommit: () -> Void - var onTextChange: (String) -> Void + var font: NSFont = .systemFont(ofSize: 13, weight: .regular) var maxLength: Int? var insets: NSSize? - - init(text: Binding, isEditable: Bool = true, font: NSFont?, onEditingChanged: @escaping () -> Void = {}, onCommit: @escaping () -> Void = {}, onTextChange: @escaping (String) -> Void = { _ in }, maxLength: Int? = nil, insets: NSSize? = nil) { - - self._text = text - self.isEditable = isEditable - self.font = font ?? .systemFont(ofSize: 13, weight: .regular) - self.onEditingChanged = onEditingChanged - self.onCommit = onCommit - self.onTextChange = onTextChange - self.maxLength = maxLength - self.insets = insets - } + var cornerRadius: CGFloat = 0 + var backgroundColor: NSColor? = .textEditorBackgroundColor + var textColor: NSColor? = .textColor + var focusRingType: NSFocusRingType = .default + var isFocusedOnAppear: Bool = true func makeCoordinator() -> Coordinator { return Coordinator(self) @@ -52,7 +44,12 @@ struct EditableTextView: NSViewRepresentable { text: text, isEditable: isEditable, font: font, + textColor: textColor, insets: insets, + isFocusedOnAppear: isFocusedOnAppear, + focusRingType: focusRingType, + cornerRadius: cornerRadius, + backgroundColor: backgroundColor, delegate: context.coordinator ) return textView @@ -67,7 +64,7 @@ struct EditableTextView: NSViewRepresentable { extension EditableTextView { - final class Coordinator: NSObject, NSTextViewDelegate { + final class Coordinator: NSObject, NSTextViewDelegate, NSControlTextEditingDelegate { var parent: EditableTextView var selectedRanges: [NSValue] = [] @@ -76,15 +73,6 @@ extension EditableTextView { self.parent = parent } - func textDidBeginEditing(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - self.parent.text = textView.string - self.parent.onEditingChanged() - } - func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } @@ -106,13 +94,10 @@ extension EditableTextView { final class CustomTextView: NSView { - weak var delegate: NSTextViewDelegate? - var text: String { didSet { - if textView.string != text { - textView.string = text - } + guard textView.string != text else { return } + textView.string = text } } @@ -123,87 +108,157 @@ final class CustomTextView: NSView { } } + private let isFocusedOnAppear: Bool + let scrollView: NSScrollView let textView: NSTextView // MARK: - Init - init(text: String = "", isEditable: Bool, font: NSFont, insets: NSSize? = nil, delegate: NSTextViewDelegate? = nil, selectedRanges: [NSValue] = []) { + init(text: String = "", isEditable: Bool, font: NSFont, textColor: NSColor? = nil, insets: NSSize? = nil, isFocusedOnAppear: Bool = false, focusRingType: NSFocusRingType = .default, cornerRadius: CGFloat = 0, backgroundColor: NSColor? = nil, delegate: NSTextViewDelegate? = nil, selectedRanges: [NSValue] = []) { self.text = text self.selectedRanges = selectedRanges + self.isFocusedOnAppear = isFocusedOnAppear - self.delegate = delegate + self.scrollView = RoundedCornersScrollView(cornerRadius: cornerRadius) - scrollView = NSScrollView() - scrollView.drawsBackground = true - scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalRuler = false - scrollView.autoresizingMask = [.width, .height] - scrollView.translatesAutoresizingMaskIntoConstraints = false - - let contentSize = scrollView.contentSize let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() textStorage.addLayoutManager(layoutManager) - let textContainer = NSTextContainer(containerSize: scrollView.frame.size) + let textContainer = NSTextContainer(containerSize: .zero) textContainer.widthTracksTextView = true - textContainer.containerSize = NSSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude) + textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) layoutManager.addTextContainer(textContainer) - textView = NSTextView(frame: .zero, textContainer: textContainer) + self.textView = NSTextView(frame: .zero, textContainer: textContainer) + + super.init(frame: .zero) + + setupScrollView(cornerRadius: cornerRadius, focusRingType: focusRingType, backgroundColor: backgroundColor) + setupTextView(isEditable: isEditable, font: font, textColor: textColor, insets: insets, delegate: delegate) + setupScrollViewConstraints() + } + + required init?(coder: NSCoder) { + fatalError("CustomTextView: Bad initializer") + } + + private func setupScrollView(cornerRadius: CGFloat, focusRingType: NSFocusRingType, backgroundColor: NSColor?) { + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalRuler = false + scrollView.autoresizingMask = [.width, .height] + scrollView.translatesAutoresizingMaskIntoConstraints = false + if let backgroundColor { + scrollView.drawsBackground = true + scrollView.backgroundColor = backgroundColor + } else { + scrollView.drawsBackground = false + } + scrollView.focusRingType = focusRingType + if cornerRadius > 0 { + scrollView.wantsLayer = true + scrollView.layer!.cornerRadius = cornerRadius + } + } + + private func setupTextView(isEditable: Bool, font: NSFont, textColor: NSColor?, insets: NSSize?, delegate: NSTextViewDelegate?) { textView.autoresizingMask = .width - textView.backgroundColor = NSColor(named: "PWMEditingControlColor")! - textView.delegate = self.delegate - textView.drawsBackground = true + textView.delegate = delegate + textView.drawsBackground = false textView.font = font textView.isEditable = isEditable textView.isHorizontallyResizable = false textView.isVerticallyResizable = true textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.minSize = NSSize(width: 0, height: contentSize.height) - textView.textColor = NSColor.labelColor + textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height) textView.allowsUndo = true textView.string = text + textView.wantsLayer = true + if let textColor { + textView.textColor = textColor + } if let insets { textView.textContainerInset = insets } if !selectedRanges.isEmpty { textView.selectedRanges = selectedRanges } - - super.init(frame: .zero) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + private func setupScrollViewConstraints() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor) + ]) + + scrollView.documentView = textView } // MARK: - Life cycle - override func viewWillDraw() { - super.viewWillDraw() - setupScrollViewConstraints() - setupTextView() + override func viewDidMoveToWindow() { + if isFocusedOnAppear, let window { + window.makeFirstResponder(textView) + } } +} - func setupScrollViewConstraints() { - scrollView.translatesAutoresizingMaskIntoConstraints = false - addSubview(scrollView) +final class RoundedCornersScrollView: NSScrollView { + + let cornerRadius: CGFloat + + init(frame: NSRect = .zero, cornerRadius: CGFloat) { + self.cornerRadius = cornerRadius + super.init(frame: frame) + } + required init?(coder: NSCoder) { + fatalError("RoudedCornersScrollView: Bad initializer") + } + + override func drawFocusRingMask() { + NSBezierPath(roundedRect: bounds, xRadius: cornerRadius, yRadius: cornerRadius).fill() + } + +} + +#Preview { { + + struct PreviewView: View { + @State var text = """ NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor) ]) - } + """ + + var body: some View { + VStack(spacing: 10) { + EditableTextView(text: $text, + font: .systemFont(ofSize: 18), + insets: NSSize(width: 15, height: 10), + cornerRadius: 15, + backgroundColor: .textBackgroundColor, + textColor: .purple, + focusRingType: .exterior, + isFocusedOnAppear: true) + + TextField("", text: .constant("")) + }.padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)) + } - func setupTextView() { - scrollView.documentView = textView } + return PreviewView() -} +}() } From 3916eadc44b2a1b0ac3d66dacf9b8986ffa0d6c3 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 21 Dec 2023 19:01:38 +0600 Subject: [PATCH 79/83] fix invalid default profile selection --- DuckDuckGo/DataImport/DataImport.swift | 16 ++++- .../DataImport/DataImportViewModelTests.swift | 62 ++++++++++++++++--- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 0c92606062..356382c051 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -202,15 +202,25 @@ enum DataImport { } var defaultProfile: BrowserProfile? { + let preferredProfileName: String? switch browser { case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: - return profiles.first { $0.profileName == Constants.chromiumDefaultProfileName } ?? profiles.first + preferredProfileName = Constants.chromiumDefaultProfileName + return validImportableProfiles.first { $0.profileName == Constants.chromiumDefaultProfileName } ?? validImportableProfiles.first ?? profiles.first case .firefox, .tor: - return profiles.first { $0.profileName == Constants.firefoxDefaultProfileName } ?? profiles.first + preferredProfileName = Constants.firefoxDefaultProfileName case .safari, .safariTechnologyPreview, .bitwarden, .lastPass, .onePassword7, .onePassword8: - return profiles.first + preferredProfileName = nil } + lazy var validImportableProfiles = self.validImportableProfiles + if let preferredProfileName, + let preferredProfile = validImportableProfiles.first(where: { $0.profileName == Constants.firefoxDefaultProfileName }) { + + return preferredProfile + } + return validImportableProfiles.first ?? profiles.first } + } struct BrowserProfile: Comparable { diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift index 12aebf14cb..7f09d54e00 100644 --- a/UnitTests/DataImport/DataImportViewModelTests.swift +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -114,14 +114,11 @@ import XCTest } func testWhenProfilesAreLoaded_defaultProfileIsSelected() { - model = DataImportViewModel(importSource: .firefox, loadProfiles: { source in - XCTAssertEqual(source, .firefox) - return .init(browser: source, profiles: [.test(for: source), .default(for: source)]) - }) + setupModel(with: .firefox, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) XCTAssertEqual(model.selectedProfile, .default(for: .firefox)) } - func testWhenInvalidProfilesArePresent_onlyValidProfilesShown() { + func testWhenInvalidProfilesArePresent_onlyValidProfilesShownAndFirstValidProfileSelected() { for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { guard let browser = ThirdPartyBrowser.browser(for: source) else { continue } @@ -147,12 +144,63 @@ import XCTest .test(for: browser), .test2(for: browser), .test3(for: browser), - ]) + ], "\(browser)") + XCTAssertEqual(model.selectedProfile, .test(for: browser), "\(browser)") + } + } + + func testWhenDefaultProfileIsInvalidAndOnlyOneValidProfileIsPresent_validProfileSelected() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { continue } + + model = DataImportViewModel(importSource: source, loadProfiles: { browser in + .init(browser: browser, profiles: [ + .default(for: browser), + .test(for: browser), + ]) { profile in + { // swiftlint:disable:this opening_brace + switch profile { + case .default(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + default: .init(logins: .available, bookmarks: .available) + } + } + } + }) + + XCTAssertEqual(model.selectedProfile, .test(for: browser)) + } + } + + func testWhenNoValidProfilesPresent_noProfilesShownAndDefaultProfileSelected() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { continue } + + model = DataImportViewModel(importSource: source, loadProfiles: { browser in + .init(browser: browser, profiles: [ + .default(for: browser), + .test(for: browser), + .test2(for: browser), + .test3(for: browser), + ]) { profile in + { // swiftlint:disable:this opening_brace + switch profile { + case .default(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + case .test(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + case .test2(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + default: .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + } + } + } + }) + + XCTAssertEqual(model.browserProfiles?.validImportableProfiles, [], "\(browser)") + XCTAssertEqual(model.selectedProfile, .default(for: browser), "\(browser)") } } func testWhenImportSourceChanged_AnotherDefaultProfileIsSelected() { - model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .default(for: $0) ]) }) + setupModel(with: .firefox, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + model.selectedProfile = .test(for: .firefox) model.update(with: .chromium) XCTAssertEqual(model.selectedProfile, .default(for: .chrome)) } From 5199d1593d55512d4cbf1eb9e9381c609d1823d5 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 21 Dec 2023 19:19:30 +0600 Subject: [PATCH 80/83] cleanup files committed by mistake --- .../TestBookmarksData/bookmarks_chrome2.html | 0 .../TestBookmarksData/bookmarks_firefox2.html | 13 ------------- .../TestBookmarksData/bookmarks_netscape.html | 0 .../TestBookmarksData/bookmarks_safari_tp.html | 0 4 files changed, 13 deletions(-) delete mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome2.html delete mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html delete mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_netscape.html delete mode 100644 UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari_tp.html diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome2.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome2.html deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html deleted file mode 100644 index 5edc0e29da..0000000000 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox2.html +++ /dev/null @@ -1,13 +0,0 @@ -
-

Folder Name 1

-
-
Help and Tutorials -
Customize Firefox -
- -

Folder Name B

-
-
Get Involved -
About Us -
-
diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_netscape.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_netscape.html deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari_tp.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari_tp.html deleted file mode 100644 index e69de29bb2..0000000000 From 761a44494bc48d6e88e982ae9faae570949e95f4 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 22 Dec 2023 12:25:42 +0600 Subject: [PATCH 81/83] last touches --- .../Bookmarks/Safari/SafariBookmarksReader.swift | 16 ++++++++++++---- .../Bookmarks/Safari/SafariDataImporter.swift | 7 +++++-- DuckDuckGo/DataImport/DataImport.swift | 3 ++- .../Logins/Chromium/ChromiumDataImporter.swift | 1 - .../DataImport/Model/DataImportViewModel.swift | 2 ++ DuckDuckGo/DataImport/View/FileImportView.swift | 6 +++--- DuckDuckGo/Localizable.xcstrings | 8 ++++---- .../SecureVault/View/EditableTextView.swift | 2 +- 8 files changed, 29 insertions(+), 16 deletions(-) diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift index 2a0253fe94..747fdffa37 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift @@ -41,6 +41,8 @@ final class SafariBookmarksReader { case readPlist case getTopLevelEntries + case getChildren + case entryNotDict } var action: DataImportAction { .bookmarks } @@ -74,7 +76,11 @@ final class SafariBookmarksReader { func validateFileReadAccess() -> DataImportResult { if !FileManager.default.isReadableFile(atPath: safariBookmarksFileURL.path) { - return .failure(ImportError(type: .readPlist, underlyingError: CocoaError(.fileReadNoPermission, userInfo: [kCFErrorURLKey as String: safariBookmarksFileURL]))) + do { + try _=Data(contentsOf: safariBookmarksFileURL) + } catch { + return .failure(ImportError(type: .readPlist, underlyingError: error)) + } } return .success( () ) } @@ -83,13 +89,15 @@ final class SafariBookmarksReader { currentOperationType = .readPlist let plistData = try readPropertyList() - guard let topLevelEntries = plistData[Constants.bookmarkChildrenKey] as? [[String: AnyObject]] else { throw ImportError(type: .getTopLevelEntries, underlyingError: nil) } + guard let children = plistData[Constants.bookmarkChildrenKey] else { throw ImportError(type: .getChildren, underlyingError: nil) } + guard let topLevelEntries = children as? [Any] else { throw ImportError(type: .getTopLevelEntries, underlyingError: nil) } var bookmarksBar: ImportedBookmarks.BookmarkOrFolder? var otherBookmarks: [ImportedBookmarks.BookmarkOrFolder] = [] - for entry in topLevelEntries - where ((entry[Constants.typeKey] as? String) == Constants.listType) || ((entry[Constants.typeKey] as? String) == Constants.leafType) { + for entry in topLevelEntries { + guard let entry = entry as? [String: AnyObject] else { throw ImportError(type: .entryNotDict, underlyingError: nil) } + guard (entry[Constants.typeKey] as? String) == Constants.listType || (entry[Constants.typeKey] as? String) == Constants.leafType else { continue } if let title = entry[Constants.titleKey] as? String, title == Constants.readingListKey { continue diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift index 2f4b64427e..04473b5742 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift @@ -16,14 +16,17 @@ // limitations under the License. // +import AppKit import Foundation final class SafariDataImporter: DataImporter { @MainActor - static func requestDataDirectoryPermission(for dataDirectoryUrl: URL) -> URL? { + static func requestDataDirectoryPermission(for fileUrl: URL) -> URL? { let openPanel = NSOpenPanel() - openPanel.directoryURL = dataDirectoryUrl + // if file does not exist, grant permission to its parent folder + openPanel.directoryURL = fileUrl.deletingLastPathComponent() + openPanel.directoryURL = fileUrl openPanel.message = UserText.bookmarkImportSafariRequestPermissionButtonTitle openPanel.allowsOtherFileTypes = false openPanel.canChooseFiles = false diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 356382c051..9495409727 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -214,7 +214,7 @@ enum DataImport { } lazy var validImportableProfiles = self.validImportableProfiles if let preferredProfileName, - let preferredProfile = validImportableProfiles.first(where: { $0.profileName == Constants.firefoxDefaultProfileName }) { + let preferredProfile = validImportableProfiles.first(where: { $0.profileName == preferredProfileName }) { return preferredProfile } @@ -449,6 +449,7 @@ protocol DataImporter { /// validate file access/encryption password requirement before starting import. Returns non-empty dictionary with failures if access validation fails. func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? + /// Start import process. Returns cancellable TaskWithProgress func importData(types: Set) -> DataImportTask func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift index e9b514c274..1531b9f77c 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift @@ -46,7 +46,6 @@ internal class ChromiumDataImporter: DataImporter { return [.passwords, .bookmarks] } - /// Start import process. Can throw synchronously if pre-import checks fail (e.g. file access) func importData(types: Set) -> DataImportTask { .detachedWithProgress { updateProgress in do { diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 66600d44ec..bf3e4df183 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -283,6 +283,7 @@ struct DataImportViewModel { switch error { // chromium user denied keychain prompt error case let error as ChromiumLoginReader.ImportError where error.type == .userDeniedKeychainPrompt: + Pixel.fire(.dataImportFailed(source: importSource, error: error)) // stay on the same screen return true @@ -304,6 +305,7 @@ struct DataImportViewModel { break } log("file read no permission for \(url.path)") + Pixel.fire(.dataImportFailed(source: importSource, error: importError)) screen = .getReadPermission(url) return true diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift index 9db57cec9e..faab1cf62b 100644 --- a/DuckDuckGo/DataImport/View/FileImportView.swift +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -93,7 +93,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ - Instructions to import Passwords as CSV from Chromium-based browsers. + Instructions to import Passwords as CSV from Cốc Cốc browser. %N$d - step number %2$s - browser name (Cốc Cốc) %5$@ - hamburger menu icon @@ -113,7 +113,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ - Instructions to import Passwords as CSV from Chromium-based browsers. + Instructions to import Passwords as CSV from Opera browser. %N$d - step number %2$s - browser name (Opera) %8$@ - “Select Passwords CSV File” button @@ -149,7 +149,7 @@ func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImpo %d Save the passwords file someplace you can find it (e.g., Desktop) %d %@ """, comment: """ - Instructions to import Passwords as CSV from Chromium-based browsers. + Instructions to import Passwords as CSV from Opera GX browsers. %N$d - step number %2$s - browser name (Opera GX) %5$@ - menu button icon diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index ec3c2cf4a5..8eb9995240 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -3559,7 +3559,7 @@ } }, "import.csv.instructions.coccoc" : { - "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name (Cốc Cốc)\n%5$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Cốc Cốc browser.\n%N$d - step number\n%2$s - browser name (Cốc Cốc)\n%5$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3631,7 +3631,7 @@ } }, "import.csv.instructions.opera" : { - "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name (Opera)\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Opera browser.\n%N$d - step number\n%2$s - browser name (Opera)\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3643,7 +3643,7 @@ } }, "import.csv.instructions.operagx" : { - "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name (Opera GX)\n%5$@ - menu button icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "comment" : "Instructions to import Passwords as CSV from Opera GX browsers.\n%N$d - step number\n%2$s - browser name (Opera GX)\n%5$@ - menu button icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -9192,4 +9192,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/DuckDuckGo/SecureVault/View/EditableTextView.swift b/DuckDuckGo/SecureVault/View/EditableTextView.swift index d5ccbcde34..80c3a91223 100644 --- a/DuckDuckGo/SecureVault/View/EditableTextView.swift +++ b/DuckDuckGo/SecureVault/View/EditableTextView.swift @@ -64,7 +64,7 @@ struct EditableTextView: NSViewRepresentable { extension EditableTextView { - final class Coordinator: NSObject, NSTextViewDelegate, NSControlTextEditingDelegate { + final class Coordinator: NSObject, NSTextViewDelegate { var parent: EditableTextView var selectedRanges: [NSValue] = [] From 04e69ebceaa86e9e2d2d68141e8d358725b95971 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 22 Dec 2023 12:59:32 +0600 Subject: [PATCH 82/83] Report browser version in Pixel on data import failure (#1873) Task/Issue URL: https://app.asana.com/0/1199230911884351/1205627400731993/f - Adds source browser version parameter to the Data import failure Pixel --- DuckDuckGo.xcodeproj/project.pbxproj | 9 ++ .../Extensions/NSWorkspaceExtension.swift | 8 ++ .../Bookmarks/Safari/SafariDataImporter.swift | 2 +- .../DataImport/ChromiumPreferences.swift | 23 ++++ DuckDuckGo/DataImport/DataImport.swift | 87 +++++++++---- .../FirefoxCompatibilityPreferences.swift | 51 ++++++++ .../Chromium/ChromiumDataImporter.swift | 2 +- .../Logins/Firefox/FirefoxDataImporter.swift | 2 +- .../Model/DataImportViewModel.swift | 15 ++- DuckDuckGo/DataImport/ThirdPartyBrowser.swift | 15 ++- DuckDuckGo/Statistics/PixelEvent.swift | 6 +- DuckDuckGo/Statistics/PixelParameters.swift | 11 +- .../PixelKit/PixelKit+Parameters.swift | 1 + .../DataImport/BrowserProfileTests.swift | 117 +++++++++++++++++- .../DataImport/DataImportViewModelTests.swift | 2 +- 15 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 DuckDuckGo/DataImport/FirefoxCompatibilityPreferences.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9bebf5a189..8524e61cb3 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2851,6 +2851,10 @@ B6B4D1C52B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; B6B4D1C62B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; B6B4D1C82B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1CA2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */; }; + B6B4D1CB2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */; }; + B6B4D1CC2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */; }; + B6B4D1CD2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */; }; B6B4D1CF2B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; B6B4D1D02B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; B6B4D1D22B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; @@ -4195,6 +4199,7 @@ B6B2400D28083B49001B8F3A /* WebViewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContainerView.swift; sourceTree = ""; }; B6B3E0DC2657E9CF0040E0A2 /* NSScreenExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSScreenExtension.swift; sourceTree = ""; }; B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportReportModel.swift; sourceTree = ""; }; + B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxCompatibilityPreferences.swift; sourceTree = ""; }; B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportNoDataView.swift; sourceTree = ""; }; B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSummaryView.swift; sourceTree = ""; }; B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestFilePermissionView.swift; sourceTree = ""; }; @@ -5400,6 +5405,7 @@ 4B5A4F4B27F3A5AA008FBD88 /* NSNotificationName+DataImport.swift */, 4B59024726B3673600489384 /* ThirdPartyBrowser.swift */, 4B7A57CE279A4EF300B1C70E /* ChromiumPreferences.swift */, + B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */, 4BB99CF326FE191E001E4761 /* Bookmarks */, 4B723DF126B0002B00E14D75 /* Logins */, 4B723DEC26B0002B00E14D75 /* View */, @@ -9633,6 +9639,7 @@ 3706FBBD293F65D500E42796 /* Preferences.swift in Sources */, 3706FBBE293F65D500E42796 /* DownloadListViewModel.swift in Sources */, 3706FBBF293F65D500E42796 /* BookmarkManagementDetailViewController.swift in Sources */, + B6B4D1CB2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, 3706FBC0293F65D500E42796 /* CSVImporter.swift in Sources */, 3706FBC1293F65D500E42796 /* StartupPreferences.swift in Sources */, 3706FBC2293F65D500E42796 /* MainMenu.swift in Sources */, @@ -10369,6 +10376,7 @@ 4B9579542AC7AE700062CA31 /* DownloadListStore.swift in Sources */, 4B9579552AC7AE700062CA31 /* Logging.swift in Sources */, 4B9579562AC7AE700062CA31 /* CrashReportPromptPresenter.swift in Sources */, + B6B4D1CD2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, 4B9579572AC7AE700062CA31 /* BWCredential.swift in Sources */, 4B9579582AC7AE700062CA31 /* PreferencesRootView.swift in Sources */, 4B9579592AC7AE700062CA31 /* AppStateChangedPublisher.swift in Sources */, @@ -11564,6 +11572,7 @@ EA0BA3A9272217E6002A0B6C /* ClickToLoadUserScript.swift in Sources */, AAA892EA250A4CEF005B37B2 /* WindowControllersManager.swift in Sources */, 85C5991B27D10CF000E605B2 /* FireAnimationView.swift in Sources */, + B6B4D1CA2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, AA6197C4276B314D008396F0 /* FaviconUrlReference.swift in Sources */, B696AFFB2AC5924800C93203 /* FileLineError.swift in Sources */, 85CC1D7B26A05ECF0062F04E /* PasswordManagementItemListModel.swift in Sources */, diff --git a/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift b/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift index 294c9d7401..8e7c81a0ac 100644 --- a/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift @@ -58,4 +58,12 @@ extension NSWorkspace { return missionControlWindows.count > allScreenSizes.count } + @available(macOS, obsoleted: 14.0, message: "This needs to be removed as it‘s no longer necessary.") + @nonobjc func urls(forApplicationsWithBundleId bundleId: String) -> [URL] { + if #available(macOS 12.0, *) { + return self.urlsForApplications(withBundleIdentifier: bundleId) + } + return LSCopyApplicationURLsForBundleIdentifier(bundleId as CFString, nil)?.takeRetainedValue() as? [URL] ?? [] + } + } diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift index 04473b5742..5da0fbb2d0 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift @@ -115,7 +115,7 @@ final class SafariDataImporter: DataImporter { await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(source: source, error: error)) + Pixel.fire(.dataImportFailed(source: source, sourceVersion: profile.installedAppsMajorVersionDescription(), error: error)) } } diff --git a/DuckDuckGo/DataImport/ChromiumPreferences.swift b/DuckDuckGo/DataImport/ChromiumPreferences.swift index acfcea22eb..b0318927ee 100644 --- a/DuckDuckGo/DataImport/ChromiumPreferences.swift +++ b/DuckDuckGo/DataImport/ChromiumPreferences.swift @@ -28,10 +28,20 @@ struct ChromiumPreferences: Decodable { let name: String? let createdByVersion: String? } + struct Extensions: Decodable { + let lastChromeVersion: String? + let lastOperaVersion: String? + } + + enum Constants { + static let chromiumPreferencesFileName = "Preferences" + } let accountInfo: [AccountInfo]? let profile: Profile + let extensions: Extensions? + init(from data: Data) throws { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase @@ -39,6 +49,13 @@ struct ChromiumPreferences: Decodable { self = try decoder.decode(Self.self, from: data) } + init(profileURL: URL, fileStore: FileStore = FileManager.default) throws { + guard let preferencesData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.chromiumPreferencesFileName)) else { + throw CocoaError(.fileReadUnknown) + } + try self.init(from: preferencesData) + } + var profileName: String? { for account in accountInfo ?? [] { switch (account.fullName, account.email) { @@ -54,4 +71,10 @@ struct ChromiumPreferences: Decodable { return profile.name } + var appVersion: String? { + // profile.createdByVersion updated on Chrome launch; + // if it‘s missing - check extensions.last_chrome_version or last_opera_version - for Opera[GX] + profile.createdByVersion ?? extensions?.lastChromeVersion ?? extensions?.lastOperaVersion + } + } diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 9495409727..fc8e6094d9 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -129,6 +129,23 @@ enum DataImport { } } + func installedAppsMajorVersionDescription(selectedProfile: BrowserProfile?) -> String? { + let installedVersions: Set + if let appVersion = selectedProfile?.appVersion, !appVersion.isEmpty { + installedVersions = [appVersion] + } else if let versions = ThirdPartyBrowser.browser(for: self)?.installedAppsVersions { + installedVersions = versions + } else { + return nil + } + return Set(installedVersions.map { + // get major version + $0.components(separatedBy: ".")[0] // [0] component is always there even if no "." + }.sorted()) + // list installed browsers major versions separated + .joined(separator: "; ") + } + } enum DataType: String, Hashable, CaseIterable, CustomStringConvertible { @@ -226,19 +243,49 @@ enum DataImport { struct BrowserProfile: Comparable { enum Constants { - static let chromiumPreferencesFileName = "Preferences" static let chromiumSystemProfileName = "System Profile" } let profileURL: URL var profileName: String { - return chromiumPreferences?.profileName ?? fallbackProfileName + if profileURL.lastPathComponent == Constants.chromiumSystemProfileName { + return Constants.chromiumSystemProfileName + } + + return profilePreferences?.profileName ?? fallbackProfileName } let browser: ThirdPartyBrowser private let fileStore: FileStore private let fallbackProfileName: String - let chromiumPreferences: ChromiumPreferences? + + enum ProfilePreferences { + case chromium(ChromiumPreferences) + case firefox(FirefoxCompatibilityPreferences) + + var appVersion: String? { + switch self { + case .chromium(let preferences): preferences.appVersion + case .firefox(let preferences): preferences.lastVersion + } + } + + var profileName: String? { + switch self { + case .chromium(let preferences): preferences.profileName + case .firefox: nil + } + } + + var isChromium: Bool { + if case .chromium = self { true } else { false } + } + } + let profilePreferences: ProfilePreferences? + + var appVersion: String? { + profilePreferences?.appVersion + } init(browser: ThirdPartyBrowser, profileURL: URL, fileStore: FileStore = FileManager.default) { self.browser = browser @@ -246,7 +293,17 @@ enum DataImport { self.profileURL = profileURL self.fallbackProfileName = Self.getDefaultProfileName(at: profileURL) - self.chromiumPreferences = Self.getChromiumProfilePreferences(at: profileURL, fileStore: fileStore) + + switch browser { + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: + self.profilePreferences = (try? ChromiumPreferences(profileURL: profileURL, fileStore: fileStore)) + .map(ProfilePreferences.chromium) + case .firefox, .tor: + self.profilePreferences = (try? FirefoxCompatibilityPreferences(profileURL: profileURL, fileStore: fileStore)) + .map(ProfilePreferences.firefox) + case .bitwarden, .safari, .safariTechnologyPreview, .lastPass, .onePassword7, .onePassword8: + self.profilePreferences = nil + } } enum ProfileDataItemValidationResult { @@ -329,24 +386,6 @@ enum DataImport { return profileURL.lastPathComponent.components(separatedBy: ".").last ?? profileURL.lastPathComponent } - private static func getChromiumProfilePreferences(at profileURL: URL, fileStore: FileStore) -> ChromiumPreferences? { - guard let profileDirectoryContents = try? fileStore.directoryContents(at: profileURL.path) else { - return nil - } - - guard profileURL.lastPathComponent != Constants.chromiumSystemProfileName else { - return nil - } - - if profileDirectoryContents.contains(Constants.chromiumPreferencesFileName), - let preferencesData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.chromiumPreferencesFileName)), - let preferences = try? ChromiumPreferences(from: preferencesData) { - return preferences - } - - return nil - } - static func < (lhs: DataImport.BrowserProfile, rhs: DataImport.BrowserProfile) -> Bool { // first sort by profiles folder name if multiple profiles folders are present (Chrome, Chrome Canary…) let profilesDirName1 = lhs.profileURL.deletingLastPathComponent().lastPathComponent @@ -361,6 +400,10 @@ enum DataImport { static func == (lhs: DataImport.BrowserProfile, rhs: DataImport.BrowserProfile) -> Bool { return lhs.profileURL == rhs.profileURL } + + func installedAppsMajorVersionDescription() -> String? { + self.browser.importSource.installedAppsMajorVersionDescription(selectedProfile: self) + } } enum ErrorType: String, CustomStringConvertible, CaseIterable { diff --git a/DuckDuckGo/DataImport/FirefoxCompatibilityPreferences.swift b/DuckDuckGo/DataImport/FirefoxCompatibilityPreferences.swift new file mode 100644 index 0000000000..993e2eb25b --- /dev/null +++ b/DuckDuckGo/DataImport/FirefoxCompatibilityPreferences.swift @@ -0,0 +1,51 @@ +// +// FirefoxCompatibilityPreferences.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import Foundation + +struct FirefoxCompatibilityPreferences { + + enum Constants { + static let firefoxCompatibilityPreferencesFileName = "compatibility.ini" + } + + let lastVersion: String? + + private static let lastVersionRegex = regex("^\\s*LastVersion\\s*=\\s*(\\S+)\\s*$") + + init(from data: Data) { + var lastVersion: String? + data.utf8String()?.enumerateLines(invoking: { line, stop in + guard let match = Self.lastVersionRegex.firstMatch(in: line, options: [], range: line.fullRange), + let range = Range(match.range(at: 1), in: line) else { return } + lastVersion = String(line[range]) + stop = true + }) + + self.lastVersion = lastVersion + } + + init(profileURL: URL, fileStore: FileStore = FileManager.default) throws { + guard let preferencesData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.firefoxCompatibilityPreferencesFileName)) else { + throw CocoaError(.fileReadUnknown) + } + self.init(from: preferencesData) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift index 1531b9f77c..a387ac7d34 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift @@ -139,7 +139,7 @@ internal class ChromiumDataImporter: DataImporter { await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(source: source, error: error)) + Pixel.fire(.dataImportFailed(source: source, sourceVersion: profile.installedAppsMajorVersionDescription(), error: error)) } } diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift index 3160979618..a6b87ee62b 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift @@ -139,7 +139,7 @@ internal class FirefoxDataImporter: DataImporter { await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(source: source, error: error)) + Pixel.fire(.dataImportFailed(source: source, sourceVersion: profile.installedAppsMajorVersionDescription(), error: error)) } } diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index bf3e4df183..7d982588ad 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -248,7 +248,7 @@ struct DataImportViewModel { // switch to file import of the failed data type displaying successful import results nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys)) } - Pixel.fire(.dataImportFailed(source: importSource, error: error)) + Pixel.fire(.dataImportFailed(source: importSource, sourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), error: error)) } } @@ -283,7 +283,7 @@ struct DataImportViewModel { switch error { // chromium user denied keychain prompt error case let error as ChromiumLoginReader.ImportError where error.type == .userDeniedKeychainPrompt: - Pixel.fire(.dataImportFailed(source: importSource, error: error)) + Pixel.fire(.dataImportFailed(source: importSource, sourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), error: error)) // stay on the same screen return true @@ -305,7 +305,7 @@ struct DataImportViewModel { break } log("file read no permission for \(url.path)") - Pixel.fire(.dataImportFailed(source: importSource, error: importError)) + Pixel.fire(.dataImportFailed(source: importSource, sourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), error: importError)) screen = .getReadPermission(url) return true @@ -710,8 +710,13 @@ extension DataImportViewModel { var reportModel: DataImportReportModel { get { - DataImportReportModel(importSource: importSource, error: summarizedError, text: userReportText, retryNumber: retryNumber) - } set { + DataImportReportModel(importSource: importSource, + importSourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), + error: summarizedError, + text: userReportText, + retryNumber: retryNumber) + } + set { userReportText = newValue.text } } diff --git a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift index 68539c3137..8692ac74e3 100644 --- a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift +++ b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift @@ -135,6 +135,19 @@ enum ThirdPartyBrowser: CaseIterable { return nil } + var installedAppsVersions: Set? { + let versions = bundleIdentifiers.all + .reduce(into: Set()) { result, bundleId in + for url in NSWorkspace.shared.urls(forApplicationsWithBundleId: bundleId) { + guard let version = ApplicationVersionReader.getVersion(of: url.path), + !version.isEmpty else { continue } + result.insert(version) + } + } + guard !versions.isEmpty else { return nil } + return versions + } + private var bundleIdentifiers: BundleIdentifiers { switch self { case .brave: return BundleIdentifiers(productionBundleID: "com.brave.Browser", relatedBundleIDs: ["com.brave.Browser.nightly"]) @@ -208,7 +221,7 @@ enum ThirdPartyBrowser: CaseIterable { } let filteredProfiles = potentialProfiles.filter { - $0.chromiumPreferences != nil + $0.profilePreferences?.isChromium == true || $0.profileName == DataImport.BrowserProfileList.Constants.chromiumDefaultProfileName || $0.profileName.hasPrefix(DataImport.BrowserProfileList.Constants.chromiumProfilePrefix) } diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 22b205d371..26bfd253ce 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -101,7 +101,7 @@ extension Pixel { case dailyOsVersionCounter - case dataImportFailed(source: DataImport.Source, error: any DataImportError) + case dataImportFailed(source: DataImport.Source, sourceVersion: String?, error: any DataImportError) case formAutofilled(kind: FormAutofillKind) case autofillItemSaved(kind: FormAutofillKind) @@ -373,9 +373,9 @@ extension Pixel.Event { case .dailyOsVersionCounter: return "m_mac_daily-os-version-counter" - case .dataImportFailed(source: let source, error: let error) where error.action == .favicons: + case .dataImportFailed(source: let source, sourceVersion: _, error: let error) where error.action == .favicons: return "m_mac_favicon-import-failed_\(source)" - case .dataImportFailed(source: let source, error: let error): + case .dataImportFailed(source: let source, sourceVersion: _, error: let error): return "m_mac_data-import-failed_\(error.action)_\(source)" case .formAutofilled(kind: let kind): diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 311fee433b..468a1b7f93 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Foundation import PixelKit extension Pixel.Event { @@ -26,7 +27,6 @@ extension Pixel.Event { return event.parameters case .debug(event: let debugEvent, error: let error): - var params = error?.pixelParameters ?? [:] if case let .assertionFailure(message, file, line) = debugEvent { @@ -37,8 +37,13 @@ extension Pixel.Event { return params - case .dataImportFailed(source: _, error: let error): - return error.pixelParameters + case .dataImportFailed(source: _, sourceVersion: let version, error: let error): + var params = error.pixelParameters + + if let version { + params[PixelKit.Parameters.sourceBrowserVersion] = version + } + return params case .launchInitial(let cohort): return [PixelKit.Parameters.experimentCohort: cohort] diff --git a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift index 9673f0dae0..b3a482cd68 100644 --- a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift +++ b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift @@ -31,6 +31,7 @@ public extension PixelKit { public static let errorDesc = "d" public static let errorCount = "c" public static let errorSource = "error_source" + public static let sourceBrowserVersion = "source_browser_version" public static let underlyingErrorCode = "ue" public static let underlyingErrorDomain = "underlyingErrorDomain" public static let underlyingErrorDesc = "ud" diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index 6e274a9e39..0c9192e823 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -89,7 +89,7 @@ class BrowserProfileTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "User Name (profile@duck.com)") - XCTAssertNotNil(profile.chromiumPreferences?.profileName) + XCTAssertNotNil(profile.profilePreferences?.profileName) } func testWhenGettingProfileName_AndProfileHasNoDetectedChromiumName_ThenDetectedNameIsUsed() { @@ -111,7 +111,7 @@ class BrowserProfileTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "ChromeProfile") - XCTAssertNotNil(profile.chromiumPreferences?.profileName) + XCTAssertNotNil(profile.profilePreferences?.profileName) } func testWhenGettingProfileName_AndChromiumPreferencesAreDetected_AndProfileNameIsSystemProfile_ThenProfileHasDefaultProfileName() { @@ -132,11 +132,122 @@ class BrowserProfileTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "System Profile") - XCTAssertNil(profile.chromiumPreferences) + XCTAssertEqual(profile.profilePreferences?.profileName, "ChromeProfile") } private func profile(named name: String) -> URL { return mockURL.appendingPathComponent(name) } + func testWhenLastChromiumVersionIsPresentInProfile_InstalledAppsReturnsMajorVersion() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { + "profile": { + "created_by_version": "118.0.5993.54" + }, + "extensions": { + "last_chrome_version": "120.0.1111.42" + } + } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "118.0.5993.54") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "118") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "118") + } + + func testWhenLastOperaVersionIsPresent_InstalledAppsReturnsMajorVersion() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { + "profile": { + }, + "extensions": { + "last_opera_version": "117.0.5938.13" + } + } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "117.0.5938.13") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "117") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "117") + } + + func testWhenLastChromiumVersionIsNotPresentInProfile_CreatedByVersionIsReturned() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { + "profile": { + "created_by_version": "118.0.5993.54" + } + } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "118.0.5993.54") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "118") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "118") + } + + func testWhenFirefoxLastVersionIsPresentInProfile_LastVersionIsReturned() { + let profileURL = profile(named: "Firefox.default") + let fileStore = FileStoreMock() + + let conf = """ + [Compatibility] + LastVersion = 118.0.1_20230927232528/20230927232528 + LastOSABI=Darwin_aarch64-gcc3 + LastPlatformDir=/Applications/Firefox.app/Contents/Resources + LastAppDir=/Applications/Firefox.app/Contents/Resources/browser + """ + + fileStore.storage["compatibility.ini"] = conf.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["compatibility.ini"] + + let profile = DataImport.BrowserProfile(browser: .firefox, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "118.0.1_20230927232528/20230927232528") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "118") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "118") + } + + func testWhenNoVersionInProfile_InstalledAppsVersionsReturned() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { "profile": {} } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertNil(profile.appVersion) + // TODO: dependency + XCTAssertEqual(profile.installedAppsMajorVersionDescription()?.sorted(), DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile)?.sorted()) + } + } diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift index 7f09d54e00..ee6c5ad4b4 100644 --- a/UnitTests/DataImport/DataImportViewModelTests.swift +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -1526,7 +1526,7 @@ import XCTest XCTAssertTrue(report.error.localizedDescription.contains(error.localizedDescription)) } } - XCTAssertEqual(report.importSourceDescription, Source.safari.importSourceName) + XCTAssertEqual(report.importSourceDescription, Source.safari.importSourceName + " " + "\(SafariVersionReader.getMajorVersion() ?? 0)") XCTAssertEqual(report.appVersion, "\(AppVersion.shared.versionNumber)") XCTAssertEqual(report.osVersion, "\(ProcessInfo.processInfo.operatingSystemVersion)") XCTAssertEqual(report.retryNumber, 2) From ec97729d04e8d69c032d779f41970dcbf3159056 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 22 Dec 2023 13:06:36 +0600 Subject: [PATCH 83/83] rm todo --- UnitTests/DataImport/BrowserProfileTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index 0c9192e823..1c2fb73f81 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -246,7 +246,6 @@ class BrowserProfileTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertNil(profile.appVersion) - // TODO: dependency XCTAssertEqual(profile.installedAppsMajorVersionDescription()?.sorted(), DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile)?.sorted()) }