diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index d5977b32bd..65b524fef3 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1694,6 +1694,14 @@ 9DEF97E12B06C4EE00764F03 /* Networking in Frameworks */ = {isa = PBXBuildFile; productRef = 9DEF97E02B06C4EE00764F03 /* Networking */; }; 9F0A2CF82B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */; }; 9F0A2CF92B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */; }; + 9F0FFFB42BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFB32BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift */; }; + 9F0FFFB52BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFB32BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift */; }; + 9F0FFFB82BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFB72BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift */; }; + 9F0FFFB92BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFB72BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift */; }; + 9F0FFFBB2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFBA2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift */; }; + 9F0FFFBC2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFBA2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift */; }; + 9F0FFFBE2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFBD2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift */; }; + 9F0FFFBF2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0FFFBD2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift */; }; 9F180D0F2B69C553000D695F /* Tab+WKUIDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F180D0E2B69C553000D695F /* Tab+WKUIDelegateTests.swift */; }; 9F180D102B69C553000D695F /* Tab+WKUIDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F180D0E2B69C553000D695F /* Tab+WKUIDelegateTests.swift */; }; 9F180D122B69C665000D695F /* DownloadsTabExtensionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F180D112B69C665000D695F /* DownloadsTabExtensionMock.swift */; }; @@ -1726,10 +1734,20 @@ 9F872DA12B90644800138637 /* ContextualMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D9F2B90644800138637 /* ContextualMenuTests.swift */; }; 9F872DA32B90920F00138637 /* BookmarkFolderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */; }; 9F872DA42B90920F00138637 /* BookmarkFolderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */; }; + 9F8D57322BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D57312BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift */; }; + 9F8D57332BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D57312BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift */; }; 9F982F0D2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */; }; 9F982F0E2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */; }; 9F982F132B822B7B00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */; }; 9F982F142B822C7400231028 /* AddEditBookmarkFolderDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */; }; + 9F9C49F62BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C49F52BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift */; }; + 9F9C49F72BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C49F52BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift */; }; + 9F9C49F92BC7BC970099738D /* BookmarkAllTabsDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C49F82BC7BC970099738D /* BookmarkAllTabsDialogView.swift */; }; + 9F9C49FA2BC7BC970099738D /* BookmarkAllTabsDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C49F82BC7BC970099738D /* BookmarkAllTabsDialogView.swift */; }; + 9F9C49FD2BC7E9830099738D /* BookmarkAllTabsDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */; }; + 9F9C49FE2BC7E9830099738D /* BookmarkAllTabsDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */; }; + 9F9C4A012BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */; }; + 9F9C4A022BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */; }; 9FA173DA2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */; }; 9FA173DB2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */; }; 9FA173DF2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173DE2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift */; }; @@ -1740,8 +1758,18 @@ 9FA173E82B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173E62B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift */; }; 9FA173EB2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */; }; 9FA173EC2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */; }; + 9FA5A0A52BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5A0A42BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift */; }; + 9FA5A0A62BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5A0A42BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift */; }; + 9FA5A0A92BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5A0A82BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift */; }; + 9FA5A0AA2BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5A0A82BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift */; }; + 9FA5A0B02BC9039200153786 /* BookmarkFolderStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5A0AC2BC9037A00153786 /* BookmarkFolderStoreMock.swift */; }; + 9FA5A0B12BC9039300153786 /* BookmarkFolderStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5A0AC2BC9037A00153786 /* BookmarkFolderStoreMock.swift */; }; 9FA75A3E2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA75A3D2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift */; }; 9FA75A3F2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA75A3D2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift */; }; + 9FAD623A2BCFDB32007F3A65 /* WebsiteInfoHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAD62392BCFDB32007F3A65 /* WebsiteInfoHelpers.swift */; }; + 9FAD623B2BCFDB32007F3A65 /* WebsiteInfoHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAD62392BCFDB32007F3A65 /* WebsiteInfoHelpers.swift */; }; + 9FAD623D2BD09DE5007F3A65 /* WebsiteInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAD623C2BD09DE5007F3A65 /* WebsiteInfoTests.swift */; }; + 9FAD623E2BD09DE5007F3A65 /* WebsiteInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAD623C2BD09DE5007F3A65 /* WebsiteInfoTests.swift */; }; 9FBD84522BB3AACB00220859 /* AttributionOriginFileProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBD84512BB3AACB00220859 /* AttributionOriginFileProvider.swift */; }; 9FBD84532BB3AACB00220859 /* AttributionOriginFileProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBD84512BB3AACB00220859 /* AttributionOriginFileProvider.swift */; }; 9FBD84562BB3ACFD00220859 /* AttributionOriginFileProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBD84552BB3ACFD00220859 /* AttributionOriginFileProviderTests.swift */; }; @@ -3415,6 +3443,10 @@ 9D9AE92B2AAB84FF0026E7DC /* DBPMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBPMocks.swift; sourceTree = ""; }; 9DB6E7222AA0DA7A00A17F3C /* LoginItems */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = LoginItems; sourceTree = ""; }; 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseBookmarkEntityTests.swift; sourceTree = ""; }; + 9F0FFFB32BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAllTabsDialogCoordinatorViewModelTests.swift; sourceTree = ""; }; + 9F0FFFB72BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogViewModelMock.swift; sourceTree = ""; }; + 9F0FFFBA2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkFolderDialogViewModelMock.swift; sourceTree = ""; }; + 9F0FFFBD2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAllTabsDialogViewModelMock.swift; sourceTree = ""; }; 9F180D0E2B69C553000D695F /* Tab+WKUIDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tab+WKUIDelegateTests.swift"; sourceTree = ""; }; 9F180D112B69C665000D695F /* DownloadsTabExtensionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsTabExtensionMock.swift; sourceTree = ""; }; 9F2606092B85C20400819292 /* AddEditBookmarkDialogViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogViewModelTests.swift; sourceTree = ""; }; @@ -3431,14 +3463,24 @@ 9F872D9C2B9058D000138637 /* Bookmarks+TabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmarks+TabTests.swift"; sourceTree = ""; }; 9F872D9F2B90644800138637 /* ContextualMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualMenuTests.swift; sourceTree = ""; }; 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFolderInfo.swift; sourceTree = ""; }; + 9F8D57312BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsBookmarkFoldersStoreTests.swift; sourceTree = ""; }; 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkFolderDialogViewModel.swift; sourceTree = ""; }; 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkFolderDialogViewModelTests.swift; sourceTree = ""; }; + 9F9C49F52BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MoreOptionsMenu+BookmarksTests.swift"; sourceTree = ""; }; + 9F9C49F82BC7BC970099738D /* BookmarkAllTabsDialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAllTabsDialogView.swift; sourceTree = ""; }; + 9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkAllTabsDialogViewModel.swift; sourceTree = ""; }; + 9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAllTabsDialogCoordinatorViewModel.swift; sourceTree = ""; }; 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogContainerView.swift; sourceTree = ""; }; 9FA173DE2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogButtonsView.swift; sourceTree = ""; }; 9FA173E22B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogFolderManagementView.swift; sourceTree = ""; }; 9FA173E62B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogStackedContentView.swift; sourceTree = ""; }; 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogView.swift; sourceTree = ""; }; + 9FA5A0A42BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsBookmarkFoldersStore.swift; sourceTree = ""; }; + 9FA5A0A82BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAllTabsDialogViewModelTests.swift; sourceTree = ""; }; + 9FA5A0AC2BC9037A00153786 /* BookmarkFolderStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFolderStoreMock.swift; sourceTree = ""; }; 9FA75A3D2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarMenuFactoryTests.swift; sourceTree = ""; }; + 9FAD62392BCFDB32007F3A65 /* WebsiteInfoHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteInfoHelpers.swift; sourceTree = ""; }; + 9FAD623C2BD09DE5007F3A65 /* WebsiteInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteInfoTests.swift; sourceTree = ""; }; 9FBD84512BB3AACB00220859 /* AttributionOriginFileProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionOriginFileProvider.swift; sourceTree = ""; }; 9FBD84552BB3ACFD00220859 /* AttributionOriginFileProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionOriginFileProviderTests.swift; sourceTree = ""; }; 9FBD845C2BB3B80300220859 /* Origin.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Origin.txt; sourceTree = ""; }; @@ -4615,6 +4657,7 @@ 566B195F29CDB7A9007E38F4 /* Mocks */, 378205FA283C277800D1D4AA /* MainMenuTests.swift */, 566B195C29CDB692007E38F4 /* MoreOptionsMenuTests.swift */, + 9F9C49F52BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift */, ); path = Menus; sourceTree = ""; @@ -6211,6 +6254,16 @@ path = DuckDuckGoDBPBackgroundAgent; sourceTree = ""; }; + 9F0FFFB62BCCAE80007C87DD /* Mocks */ = { + isa = PBXGroup; + children = ( + 9F0FFFB72BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift */, + 9F0FFFBA2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift */, + 9F0FFFBD2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; 9F872D9B2B9058B000138637 /* Extensions */ = { isa = PBXGroup; children = ( @@ -6222,9 +6275,12 @@ 9F982F102B82264400231028 /* ViewModels */ = { isa = PBXGroup; children = ( + 9F0FFFB62BCCAE80007C87DD /* Mocks */, 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */, 9F2606092B85C20400819292 /* AddEditBookmarkDialogViewModelTests.swift */, 9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */, + 9F0FFFB32BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift */, + 9FA5A0A82BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift */, ); path = ViewModels; sourceTree = ""; @@ -6242,6 +6298,7 @@ 9F56CFA82B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift */, 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */, 9F56CFB02B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift */, + 9F9C49F82BC7BC970099738D /* BookmarkAllTabsDialogView.swift */, ); path = Dialog; sourceTree = ""; @@ -6254,6 +6311,14 @@ path = Factory; sourceTree = ""; }; + 9FAD62382BCFDB1D007F3A65 /* Helpers */ = { + isa = PBXGroup; + children = ( + 9FAD62392BCFDB32007F3A65 /* WebsiteInfoHelpers.swift */, + ); + path = Helpers; + sourceTree = ""; + }; AA0877B626D515EE00B05660 /* UserAgent */ = { isa = PBXGroup; children = ( @@ -6603,6 +6668,7 @@ AA652CAB25DD820D009059CC /* Bookmarks */ = { isa = PBXGroup; children = ( + 9FAD62382BCFDB1D007F3A65 /* Helpers */, 9F872D9B2B9058B000138637 /* Extensions */, 9FA75A3C2BA00DF500DA5FA6 /* Factory */, 9F982F102B82264400231028 /* ViewModels */, @@ -6629,6 +6695,7 @@ 98A95D87299A2DF900B9B81A /* BookmarkMigrationTests.swift */, 9F872D9F2B90644800138637 /* ContextualMenuTests.swift */, 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */, + 9FAD623C2BD09DE5007F3A65 /* WebsiteInfoTests.swift */, ); path = Model; sourceTree = ""; @@ -6638,6 +6705,8 @@ children = ( AA652CB025DD825B009059CC /* LocalBookmarkStoreTests.swift */, 986189E52A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift */, + 9FA5A0AC2BC9037A00153786 /* BookmarkFolderStoreMock.swift */, + 9F8D57312BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift */, ); path = Services; sourceTree = ""; @@ -7022,6 +7091,8 @@ 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */, 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */, 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */, + 9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */, + 9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -7144,6 +7215,7 @@ B6DA06E52913F39400225DE2 /* MenuItemSelectors.swift */, AAC5E4D625D6A710007F5990 /* BookmarkStore.swift */, 987799F829999973005D8EB6 /* LocalBookmarkStore.swift */, + 9FA5A0A42BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift */, ); path = Services; sourceTree = ""; @@ -9702,6 +9774,7 @@ 98779A0129999B64005D8EB6 /* Bookmark.xcdatamodeld in Sources */, 3706FB9E293F65D500E42796 /* AboutModel.swift in Sources */, 3706FB9F293F65D500E42796 /* PasswordManagementCreditCardItemView.swift in Sources */, + 9F9C4A022BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift in Sources */, 3706FBA0293F65D500E42796 /* NSTextFieldExtension.swift in Sources */, 9FA173E82B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */, 3706FBA1293F65D500E42796 /* FireproofDomainsContainer.swift in Sources */, @@ -9764,6 +9837,7 @@ 3706FBCC293F65D500E42796 /* CustomRoundedCornersShape.swift in Sources */, 3706FBCD293F65D500E42796 /* LocaleExtension.swift in Sources */, 3706FBCE293F65D500E42796 /* SavePaymentMethodViewController.swift in Sources */, + 9FA5A0A62BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift in Sources */, 1DDC85002B835BC000670238 /* SearchPreferences.swift in Sources */, 3706FBD0293F65D500E42796 /* WebKitVersionProvider.swift in Sources */, 3706FBD1293F65D500E42796 /* NSCoderExtensions.swift in Sources */, @@ -9959,6 +10033,7 @@ 3706FC51293F65D500E42796 /* RecentlyVisitedView.swift in Sources */, B6E3E5552BBFCEE300A41922 /* NoDownloadsCellView.swift in Sources */, B645D8F729FA95440024461F /* WKProcessPoolExtension.swift in Sources */, + 9F9C49FE2BC7E9830099738D /* BookmarkAllTabsDialogViewModel.swift in Sources */, 9F514F922B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */, 3706FC52293F65D500E42796 /* MouseOverAnimationButton.swift in Sources */, B60293E72BA19ECD0033186B /* NetPPopoverManagerMock.swift in Sources */, @@ -10069,6 +10144,7 @@ 3706FCA0293F65D500E42796 /* ContiguousBytesExtension.swift in Sources */, B602E8172A1E2570006D261F /* URL+NetworkProtection.swift in Sources */, 3706FCA1293F65D500E42796 /* AdjacentItemEnumerator.swift in Sources */, + 9F9C49FA2BC7BC970099738D /* BookmarkAllTabsDialogView.swift in Sources */, 3706FCA2293F65D500E42796 /* ChromiumKeychainPrompt.swift in Sources */, 3707C71E294B5D2900682A9F /* URLRequestExtension.swift in Sources */, 3706FCA3293F65D500E42796 /* WKProcessPool+GeolocationProvider.swift in Sources */, @@ -10083,6 +10159,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9F0FFFB92BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift in Sources */, 3706FDDA293F661700E42796 /* EmbeddedTrackerDataTests.swift in Sources */, 3706FDDB293F661700E42796 /* AutofillPreferencesTests.swift in Sources */, 3706FDDC293F661700E42796 /* FileManagerExtensionTests.swift in Sources */, @@ -10115,6 +10192,7 @@ 3706FDF5293F661700E42796 /* StartupPreferencesTests.swift in Sources */, 3706FDF6293F661700E42796 /* DuckPlayerTests.swift in Sources */, 3706FDF7293F661700E42796 /* WebViewExtensionTests.swift in Sources */, + 9FA5A0AA2BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift in Sources */, 56534DEE29DF252C00121467 /* CapturingDefaultBrowserProvider.swift in Sources */, 3706FDF8293F661700E42796 /* FileStoreTests.swift in Sources */, 5603D90729B7B746007F9F01 /* MockTabViewItemDelegate.swift in Sources */, @@ -10172,7 +10250,9 @@ 857E44642A9F70F200ED77A7 /* CampaignVariantTests.swift in Sources */, 3706FE1E293F661700E42796 /* GeolocationProviderTests.swift in Sources */, 9F39106A2B68D87B00CB5112 /* ProgressExtensionTests.swift in Sources */, + 9FAD623B2BCFDB32007F3A65 /* WebsiteInfoHelpers.swift in Sources */, 3706FE1F293F661700E42796 /* AppStateChangePublisherTests.swift in Sources */, + 9FA5A0B12BC9039300153786 /* BookmarkFolderStoreMock.swift in Sources */, 3706FE20293F661700E42796 /* CLLocationManagerMock.swift in Sources */, B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, B6C843DB2BA1CAB6006FDEC3 /* FilePresenterTests.swift in Sources */, @@ -10203,10 +10283,12 @@ B630E80129C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, 3706FE31293F661700E42796 /* TabCollectionViewModelDelegateMock.swift in Sources */, 3706FE32293F661700E42796 /* BookmarksHTMLReaderTests.swift in Sources */, + 9F0FFFBC2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift in Sources */, 3706FE33293F661700E42796 /* FireTests.swift in Sources */, B60C6F8229B1B4AD007BFAA8 /* TestRunHelper.swift in Sources */, 567DA94029E8045D008AC5EE /* MockEmailStorage.swift in Sources */, 317295D32AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift in Sources */, + 9F0FFFB52BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift in Sources */, 3706FE34293F661700E42796 /* PermissionStoreTests.swift in Sources */, 3706FE35293F661700E42796 /* ThirdPartyBrowserTests.swift in Sources */, 1DFAB5232A8983E100A0F7F6 /* SetExtensionTests.swift in Sources */, @@ -10214,6 +10296,7 @@ 3706FE37293F661700E42796 /* TabCollectionViewModelTests+WithoutPinnedTabsManager.swift in Sources */, 3706FE38293F661700E42796 /* SuggestionContainerTests.swift in Sources */, 3706FE39293F661700E42796 /* TabTests.swift in Sources */, + 9FAD623E2BD09DE5007F3A65 /* WebsiteInfoTests.swift in Sources */, 3706FE3A293F661700E42796 /* MockVariantManager.swift in Sources */, 3706FE3C293F661700E42796 /* FireproofDomainsStoreMock.swift in Sources */, 3706FE3D293F661700E42796 /* DataEncryptionTests.swift in Sources */, @@ -10236,6 +10319,7 @@ 3706FE46293F661700E42796 /* EncryptedValueTransformerTests.swift in Sources */, 9F3910632B68C35600CB5112 /* DownloadsTabExtensionTests.swift in Sources */, 3706FE47293F661700E42796 /* URLExtensionTests.swift in Sources */, + 9F8D57332BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift in Sources */, 1DB9617B29F1D06D00CF5568 /* InternalUserDeciderMock.swift in Sources */, 317295D52AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift in Sources */, 3706FE49293F661700E42796 /* BookmarkNodePathTests.swift in Sources */, @@ -10261,6 +10345,7 @@ 566B196429CDB824007E38F4 /* MoreOptionsMenuTests.swift in Sources */, 9F0A2CF92B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */, 3706FE56293F661700E42796 /* FaviconManagerMock.swift in Sources */, + 9F9C49F72BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift in Sources */, 3706FE57293F661700E42796 /* LocalPinningManagerTests.swift in Sources */, 3706FE58293F661700E42796 /* HistoryStoreTests.swift in Sources */, 3706FE59293F661700E42796 /* EncryptionKeyGeneratorTests.swift in Sources */, @@ -10277,6 +10362,7 @@ 3706FE5F293F661700E42796 /* CrashReportTests.swift in Sources */, B60C6F7F29B1B41D007BFAA8 /* TestRunHelperInitializer.m in Sources */, 4B9DB0592A983B55000927DB /* MockNetworkProtectionCodeRedeemer.swift in Sources */, + 9F0FFFBF2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift in Sources */, 3706FE61293F661700E42796 /* PinnedTabsViewModelTests.swift in Sources */, 3706FE62293F661700E42796 /* PasswordManagementListSectionTests.swift in Sources */, 3706FE63293F661700E42796 /* RecentlyClosedCoordinatorMock.swift in Sources */, @@ -10645,6 +10731,7 @@ 31AA6B972B960B870025014E /* DataBrokerProtectionLoginItemPixels.swift in Sources */, B6BF5D852946FFDA006742B1 /* PrivacyDashboardTabExtension.swift in Sources */, B6E3E55B2BC0041900A41922 /* DownloadListStoreMock.swift in Sources */, + 9FA5A0A52BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift in Sources */, B6C0B23026E61D630031CB7F /* DownloadListStore.swift in Sources */, 85799C1825DEBB3F0007EC87 /* Logging.swift in Sources */, AAC30A2E268F1EE300D2D9CD /* CrashReportPromptPresenter.swift in Sources */, @@ -11152,6 +11239,7 @@ AAD86E52267A0DFF005C11BE /* UpdateController.swift in Sources */, 85A0118225AF60E700FA6A0C /* FindInPageModel.swift in Sources */, 7BA7CC4E2AD11F6F0042E5CE /* NetworkProtectionIPCTunnelController.swift in Sources */, + 9F9C49FD2BC7E9830099738D /* BookmarkAllTabsDialogViewModel.swift in Sources */, 4B9292A226670D2A00AD2C21 /* PseudoFolder.swift in Sources */, 1DDD3EC42B84F96B004CBF2B /* CookiePopupProtectionPreferences.swift in Sources */, 4BCF15D92ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */, @@ -11241,6 +11329,7 @@ B693955726F04BEC0015B914 /* MouseOverButton.swift in Sources */, AA61C0D02722159B00E6B681 /* FireInfoViewController.swift in Sources */, 9D9AE8692AA76CDC0026E7DC /* LoginItem+NetworkProtection.swift in Sources */, + 9F9C49F92BC7BC970099738D /* BookmarkAllTabsDialogView.swift in Sources */, B64C85422694590B0048FEBE /* PermissionButton.swift in Sources */, AAA0CC472533833C0079BC96 /* MoreOptionsMenu.swift in Sources */, B64C84E32692DC9F0048FEBE /* PermissionAuthorizationViewController.swift in Sources */, @@ -11337,6 +11426,7 @@ 853014D625E671A000FB8205 /* PageObserverUserScript.swift in Sources */, B677FC4F2B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, B642738227B65BAC0005DFD1 /* SecureVaultReporter.swift in Sources */, + 9F9C4A012BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift in Sources */, 4B139AFD26B60BD800894F82 /* NSImageExtensions.swift in Sources */, B62B48392ADE46FC000DECE5 /* Application.swift in Sources */, 4B9DB02C2A983B24000927DB /* WaitlistKeychainStorage.swift in Sources */, @@ -11423,6 +11513,7 @@ 37D2377C287EBDA300BCE03B /* TabIndexTests.swift in Sources */, 37534CA52811987D002621E7 /* AdjacentItemEnumeratorTests.swift in Sources */, 56B234BF2A84EFD200F2A1CC /* NavigationBarUrlExtensionsTests.swift in Sources */, + 9FAD623A2BCFDB32007F3A65 /* WebsiteInfoHelpers.swift in Sources */, C13909F42B85FD79001626ED /* AutofillDeleteAllPasswordsExecutorTests.swift in Sources */, 37534C9E28104D9B002621E7 /* TabLazyLoaderTests.swift in Sources */, B6619EF62B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */, @@ -11432,6 +11523,7 @@ 4B9292BD2667103100AD2C21 /* BookmarkOutlineViewDataSourceTests.swift in Sources */, C17CA7B22B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift in Sources */, 4BF6961D28BE911100D402D4 /* RecentlyVisitedSiteModelTests.swift in Sources */, + 9FA5A0A92BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift in Sources */, B6619F062B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */, 4BBF0917282DD6EF00EE1418 /* TemporaryFileHandlerTests.swift in Sources */, B6A5A27925B93FFF00AA7ADA /* StateRestorationManagerTests.swift in Sources */, @@ -11480,6 +11572,7 @@ 858A798826A99DBE00A75A42 /* PasswordManagementItemListModelTests.swift in Sources */, 566B196529CDB828007E38F4 /* CapturingOptionsButtonMenuDelegate.swift in Sources */, 4B8AD0B127A86D9200AE44D6 /* WKWebsiteDataStoreExtensionTests.swift in Sources */, + 9FA5A0B02BC9039200153786 /* BookmarkFolderStoreMock.swift in Sources */, B69B50472726C5C200758A2B /* VariantManagerTests.swift in Sources */, 8546DE6225C03056000CA5E1 /* UserAgentTests.swift in Sources */, 9F26060B2B85C20A00819292 /* AddEditBookmarkDialogViewModelTests.swift in Sources */, @@ -11496,9 +11589,11 @@ 85AC3B4925DAC9BD00C7D2AA /* ConfigurationStorageTests.swift in Sources */, 9F180D122B69C665000D695F /* DownloadsTabExtensionMock.swift in Sources */, AA91F83927076F1900771A0D /* PrivacyIconViewModelTests.swift in Sources */, + 9F9C49F62BC786790099738D /* MoreOptionsMenu+BookmarksTests.swift in Sources */, 4B723E0726B0003E00E14D75 /* CSVImporterTests.swift in Sources */, 4BCF15EC2ABB9AF80083F6DF /* NetworkProtectionRemoteMessageTests.swift in Sources */, B62EB47C25BAD3BB005745C6 /* WKWebViewPrivateMethodsAvailabilityTests.swift in Sources */, + 9F0FFFBB2BCCAEC2007C87DD /* AddEditBookmarkFolderDialogViewModelMock.swift in Sources */, 4BBC16A527C488C900E00A38 /* DeviceAuthenticatorTests.swift in Sources */, 4B3F641E27A8D3BD00E0C118 /* BrowserProfileTests.swift in Sources */, B6106BA026A7BE0B0013B453 /* PermissionManagerTests.swift in Sources */, @@ -11552,6 +11647,7 @@ B6106BB126A7D8720013B453 /* PermissionStoreTests.swift in Sources */, 4BF4951826C08395000547B8 /* ThirdPartyBrowserTests.swift in Sources */, 4B98D27C28D960DD003C2B6F /* FirefoxFaviconsReaderTests.swift in Sources */, + 9F0FFFBE2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift in Sources */, B60C6F7E29B1B41D007BFAA8 /* TestRunHelperInitializer.m in Sources */, 37479F152891BC8300302FE2 /* TabCollectionViewModelTests+WithoutPinnedTabsManager.swift in Sources */, AA63745424C9BF9A00AB2AC4 /* SuggestionContainerTests.swift in Sources */, @@ -11603,7 +11699,9 @@ AABAF59C260A7D130085060C /* FaviconManagerMock.swift in Sources */, 1DFAB5222A8983DE00A0F7F6 /* SetExtensionTests.swift in Sources */, 4BF6962028BEEE8B00D402D4 /* LocalPinningManagerTests.swift in Sources */, + 9F0FFFB82BCCAE9C007C87DD /* AddEditBookmarkDialogViewModelMock.swift in Sources */, AAEC74B82642E43800C2EFBC /* HistoryStoreTests.swift in Sources */, + 9FAD623D2BD09DE5007F3A65 /* WebsiteInfoTests.swift in Sources */, 9F180D0F2B69C553000D695F /* Tab+WKUIDelegateTests.swift in Sources */, 4BA1A6E6258C270800F6F690 /* EncryptionKeyGeneratorTests.swift in Sources */, B6106BB326A7F4AA0013B453 /* GeolocationServiceMock.swift in Sources */, @@ -11612,6 +11710,7 @@ 4BBF09232830812900EE1418 /* FileSystemDSL.swift in Sources */, 4B9DB05C2A983B55000927DB /* WaitlistViewModelTests.swift in Sources */, 1D1C36E329FAE8DA001FA40C /* FaviconManagerTests.swift in Sources */, + 9F0FFFB42BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift in Sources */, 4B723E0526B0003E00E14D75 /* DataImportMocks.swift in Sources */, 4B70C00227B0793D000386ED /* CrashReportTests.swift in Sources */, B6656E0D2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, @@ -11646,6 +11745,7 @@ 9FBD84562BB3ACFD00220859 /* AttributionOriginFileProviderTests.swift in Sources */, 31E163BA293A56F400963C10 /* BrokenSiteReportingReferenceTests.swift in Sources */, 1D9FDEC32B9B63C90040B78C /* DataClearingPreferencesTests.swift in Sources */, + 9F8D57322BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift in Sources */, 4B723E0826B0003E00E14D75 /* MockSecureVault.swift in Sources */, 37CD54B527F1AC1300F1F7B9 /* PreferencesSidebarModelTests.swift in Sources */, B6CA4824298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */, diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift index b3172da78e..1220f5eabb 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift @@ -28,8 +28,10 @@ protocol BookmarkManager: AnyObject { func allHosts() -> Set func getBookmark(for url: URL) -> Bookmark? func getBookmark(forUrl url: String) -> Bookmark? + func getBookmarkFolder(withId id: String) -> BookmarkFolder? @discardableResult func makeBookmark(for url: URL, title: String, isFavorite: Bool) -> Bookmark? @discardableResult func makeBookmark(for url: URL, title: String, isFavorite: Bool, index: Int?, parent: BookmarkFolder?) -> Bookmark? + func makeBookmarks(for websitesInfo: [WebsiteInfo], inNewFolderNamed folderName: String, withinParentFolder parent: ParentFolderType) func makeFolder(for title: String, parent: BookmarkFolder?, completion: @escaping (BookmarkFolder) -> Void) func remove(bookmark: Bookmark) func remove(folder: BookmarkFolder) @@ -46,7 +48,6 @@ protocol BookmarkManager: AnyObject { func move(objectUUIDs: [String], toIndex: Int?, withinParentFolder: ParentFolderType, completion: @escaping (Error?) -> Void) func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary - func handleFavoritesAfterDisablingSync() // Wrapper definition in a protocol is not supported yet @@ -138,6 +139,10 @@ final class LocalBookmarkManager: BookmarkManager { return list?[url] } + func getBookmarkFolder(withId id: String) -> BookmarkFolder? { + bookmarkStore.bookmarkFolder(withId: id) + } + @discardableResult func makeBookmark(for url: URL, title: String, isFavorite: Bool) -> Bookmark? { makeBookmark(for: url, title: title, isFavorite: isFavorite, index: nil, parent: nil) } @@ -167,6 +172,12 @@ final class LocalBookmarkManager: BookmarkManager { return bookmark } + func makeBookmarks(for websitesInfo: [WebsiteInfo], inNewFolderNamed folderName: String, withinParentFolder parent: ParentFolderType) { + bookmarkStore.saveBookmarks(for: websitesInfo, inNewFolderNamed: folderName, withinParentFolder: parent) + loadBookmarks() + requestSync() + } + func remove(bookmark: Bookmark) { guard list != nil else { return } guard let latestBookmark = getBookmark(forUrl: bookmark.url) else { diff --git a/DuckDuckGo/Bookmarks/Model/WebsiteInfo.swift b/DuckDuckGo/Bookmarks/Model/WebsiteInfo.swift index fc393b256d..a4d146abd7 100644 --- a/DuckDuckGo/Bookmarks/Model/WebsiteInfo.swift +++ b/DuckDuckGo/Bookmarks/Model/WebsiteInfo.swift @@ -18,15 +18,17 @@ import Foundation -struct WebsiteInfo { +struct WebsiteInfo: Equatable { let url: URL - let title: String? + /// Returns the title of the website if available, otherwise returns the domain of the URL. + /// If both title and and domain are nil, it returns the absolute string representation of the URL. + let title: String init?(_ tab: Tab) { guard case let .url(url, _, _) = tab.content else { return nil } self.url = url - self.title = tab.title + self.title = tab.title ?? url.host ?? url.absoluteString } } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift index 3466d1a7fa..8c8f4a6d06 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift @@ -48,9 +48,11 @@ protocol BookmarkStore { func loadAll(type: BookmarkStoreFetchPredicateType, completion: @escaping ([BaseBookmarkEntity]?, Error?) -> Void) func save(bookmark: Bookmark, parent: BookmarkFolder?, index: Int?, completion: @escaping (Bool, Error?) -> Void) + func saveBookmarks(for websitesInfo: [WebsiteInfo], inNewFolderNamed folderName: String, withinParentFolder parent: ParentFolderType) func save(folder: BookmarkFolder, parent: BookmarkFolder?, completion: @escaping (Bool, Error?) -> Void) func remove(objectsWithUUIDs: [String], completion: @escaping (Bool, Error?) -> Void) func update(bookmark: Bookmark) + func bookmarkFolder(withId id: String) -> BookmarkFolder? func update(folder: BookmarkFolder) func update(folder: BookmarkFolder, andMoveToParent parent: ParentFolderType) func add(objectsWithUUIDs: [String], to parent: BookmarkFolder?, completion: @escaping (Error?) -> Void) @@ -59,6 +61,5 @@ protocol BookmarkStore { func move(objectUUIDs: [String], toIndex: Int?, withinParentFolder: ParentFolderType, completion: @escaping (Error?) -> Void) func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary - func handleFavoritesAfterDisablingSync() } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift index 2ab57158a9..003ba7bb59 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift @@ -104,6 +104,15 @@ public final class BookmarkStoreMock: BookmarkStore { capturedBookmark = bookmark } + var bookmarkFolderWithIdCalled = false + var capturedFolderId: String? + var bookmarkFolder: BookmarkFolder? + func bookmarkFolder(withId id: String) -> BookmarkFolder? { + bookmarkFolderWithIdCalled = true + capturedFolderId = id + return bookmarkFolder + } + var updateFolderCalled = false func update(folder: BookmarkFolder) { updateFolderCalled = true @@ -133,6 +142,16 @@ public final class BookmarkStoreMock: BookmarkStore { return BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) } + var saveBookmarksInNewFolderNamedCalled = false + var capturedWebsitesInfo: [WebsiteInfo]? + var capturedNewFolderName: String? + func saveBookmarks(for websitesInfo: [WebsiteInfo], inNewFolderNamed folderName: String, withinParentFolder parent: ParentFolderType) { + saveBookmarksInNewFolderNamedCalled = true + capturedWebsitesInfo = websitesInfo + capturedNewFolderName = folderName + capturedParentFolderType = parent + } + var canMoveObjectWithUUIDCalled = false func canMoveObjectWithUUID(objectUUID uuid: String, to parent: BookmarkFolder) -> Bool { canMoveObjectWithUUIDCalled = true diff --git a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift index fb21f3222f..2a480e8333 100644 --- a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift @@ -65,6 +65,7 @@ final class LocalBookmarkStore: BookmarkStore { case missingRoot case missingFavoritesRoot case saveLoopError(Error?) + case badModelMapping } private(set) var favoritesDisplayMode: FavoritesDisplayMode @@ -339,6 +340,23 @@ final class LocalBookmarkStore: BookmarkStore { }) } + func saveBookmarks(for websitesInfo: [WebsiteInfo], inNewFolderNamed folderName: String, withinParentFolder parent: ParentFolderType) { + do { + try applyChangesAndSave { context in + // Fetch Parent folder + let parentFolder = try bookmarkEntity(for: parent, in: context) + // Create new Folder for all bookmarks + let newFolderMO = BookmarkEntity.makeFolder(title: folderName, parent: parentFolder, context: context) + // Save the bookmarks + websitesInfo.forEach { info in + _ = BookmarkEntity.makeBookmark(title: info.title, url: info.url.absoluteString, parent: newFolderMO, context: context) + } + } + } catch { + commonOnSaveErrorHandler(error) + } + } + func remove(objectsWithUUIDs identifiers: [String], completion: @escaping (Bool, Error?) -> Void) { applyChangesAndSave(changes: { [weak self] context in @@ -390,6 +408,38 @@ final class LocalBookmarkStore: BookmarkStore { } } + func bookmarkFolder(withId id: String) -> BookmarkFolder? { + let context = makeContext() + + var bookmarkFolderToReturn: BookmarkFolder? + let favoritesDisplayMode = self.favoritesDisplayMode + + context.performAndWait { + let folderFetchRequest = BaseBookmarkEntity.singleEntity(with: id) + do { + let folderFetchRequestResult = try context.fetch(folderFetchRequest) + guard let bookmarkFolderManagedObject = folderFetchRequestResult.first else { return } + + guard let bookmarkFolder = BaseBookmarkEntity.from( + managedObject: bookmarkFolderManagedObject, + parentFolderUUID: bookmarkFolderManagedObject.parent?.uuid, + favoritesDisplayMode: favoritesDisplayMode + ) as? BookmarkFolder + else { + throw BookmarkStoreError.badModelMapping + } + bookmarkFolderToReturn = bookmarkFolder + + } catch BookmarkStoreError.badModelMapping { + os_log("Failed to map BookmarkEntity to BookmarkFolder, with error: %s", log: .bookmarks, type: .error) + } catch { + os_log("Failed to fetch last saved folder for bookmarks all tabs, with error: %s", log: .bookmarks, type: .error, error.localizedDescription) + } + } + + return bookmarkFolderToReturn + } + func update(folder: BookmarkFolder) { do { _ = try applyChangesAndSave(changes: { [weak self] context in @@ -998,32 +1048,38 @@ private extension LocalBookmarkStore { } func move(entities: [BookmarkEntity], toIndex index: Int?, withinParentFolderType type: ParentFolderType, in context: NSManagedObjectContext) throws { + let newParentFolder = try bookmarkEntity(for: type, in: context) + + if let index = index, index < newParentFolder.childrenArray.count { + self.move(entities: entities, to: index, within: newParentFolder) + } else { + for bookmarkManagedObject in entities { + bookmarkManagedObject.parent = nil + newParentFolder.addToChildren(bookmarkManagedObject) + } + } + } + + func bookmarkEntity(for parentFolderType: ParentFolderType, in context: NSManagedObjectContext) throws -> BookmarkEntity { guard let rootFolder = bookmarksRoot(in: context) else { throw BookmarkStoreError.missingRoot } - let newParentFolder: BookmarkEntity + let parentFolder: BookmarkEntity - switch type { - case .root: newParentFolder = rootFolder - case .parent(let newParentUUID): - let bookmarksFetchRequest = BaseBookmarkEntity.singleEntity(with: newParentUUID) + switch parentFolderType { + case .root: + parentFolder = rootFolder + case let .parent(parentUUID): + let bookmarksFetchRequest = BaseBookmarkEntity.singleEntity(with: parentUUID) if let fetchedParent = try context.fetch(bookmarksFetchRequest).first, fetchedParent.isFolder { - newParentFolder = fetchedParent + parentFolder = fetchedParent } else { throw BookmarkStoreError.missingEntity } } - - if let index = index, index < newParentFolder.childrenArray.count { - self.move(entities: entities, to: index, within: newParentFolder) - } else { - for bookmarkManagedObject in entities { - bookmarkManagedObject.parent = nil - newParentFolder.addToChildren(bookmarkManagedObject) - } - } + return parentFolder } } @@ -1041,6 +1097,7 @@ extension LocalBookmarkStore.BookmarkStoreError: CustomNSError { case .missingRoot: return 7 case .missingFavoritesRoot: return 8 case .saveLoopError: return 9 + case .badModelMapping: return 10 } } diff --git a/DuckDuckGo/Bookmarks/Services/UserDefaultsBookmarkFoldersStore.swift b/DuckDuckGo/Bookmarks/Services/UserDefaultsBookmarkFoldersStore.swift new file mode 100644 index 0000000000..7fb3187329 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Services/UserDefaultsBookmarkFoldersStore.swift @@ -0,0 +1,48 @@ +// +// UserDefaultsBookmarkFoldersStore.swift +// +// Copyright © 2024 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 + +/// A type used to provide the ID of the folder where all tabs were last saved. +protocol BookmarkFoldersStore: AnyObject { + /// The ID of the folder where all bookmarks from the last session were saved. + var lastBookmarkAllTabsFolderIdUsed: String? { get set } +} + +final class UserDefaultsBookmarkFoldersStore: BookmarkFoldersStore { + + enum Keys { + static let bookmarkAllTabsFolderUsedKey = "bookmarks.all-tabs.last-used-folder" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + var lastBookmarkAllTabsFolderIdUsed: String? { + get { + userDefaults.string(forKey: Keys.bookmarkAllTabsFolderUsedKey) + } + set { + userDefaults.set(newValue, forKey: Keys.bookmarkAllTabsFolderUsedKey) + } + } + +} diff --git a/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift b/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift index f15465df36..8ce41c17f2 100644 --- a/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift +++ b/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift @@ -36,7 +36,6 @@ struct AddBookmarkFolderPopoverView: ModalView { isDefaultActionDisabled: model.isDefaultActionButtonDisabled, defaultAction: { _ in model.addFolder() } ) - .padding(.vertical, 16.0) .font(.system(size: 13)) .frame(width: 320) } diff --git a/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift b/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift index fba84b44bc..1faa240398 100644 --- a/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift +++ b/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift @@ -55,7 +55,6 @@ struct AddBookmarkPopoverView: View { isDefaultActionDisabled: model.isDefaultActionButtonDisabled, defaultAction: model.doneButtonAction ) - .padding(.vertical, 16.0) .font(.system(size: 13)) .frame(width: 320) } diff --git a/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkDialogView.swift b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkDialogView.swift index 2c0256bba4..78cdc6efcd 100644 --- a/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkDialogView.swift +++ b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkDialogView.swift @@ -55,7 +55,7 @@ struct AddEditBookmarkDialogView: ModalView { isDefaultActionDisabled: viewModel.bookmarkModel.isDefaultActionDisabled, defaultAction: viewModel.bookmarkModel.addOrSave ) - .frame(width: 448, height: 288) + .frame(width: 448) } private var addFolderView: some View { diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkAllTabsDialogView.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkAllTabsDialogView.swift new file mode 100644 index 0000000000..ad920b10c6 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkAllTabsDialogView.swift @@ -0,0 +1,141 @@ +// +// BookmarkAllTabsDialogView.swift +// +// Copyright © 2024 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 +import SwiftUIExtensions + +struct BookmarkAllTabsDialogView: ModalView { + @ObservedObject private var viewModel: BookmarkAllTabsDialogCoordinatorViewModel + + init(viewModel: BookmarkAllTabsDialogCoordinatorViewModel) { + self.viewModel = viewModel + } + + var body: some View { + Group { + switch viewModel.viewState { + case .bookmarkAllTabs: + bookmarkAllTabsView + case .addFolder: + addFolderView + } + } + .font(.system(size: 13)) + } + + private var bookmarkAllTabsView: some View { + BookmarkDialogContainerView( + title: viewModel.bookmarkModel.title, + middleSection: { + Text(viewModel.bookmarkModel.educationalMessage) + .multilineText() + .multilineTextAlignment(.leading) + .foregroundColor(.secondary) + .font(.system(size: 11)) + BookmarkDialogStackedContentView( + .init( + title: UserText.Bookmarks.Dialog.Field.folderName, + content: TextField("", text: $viewModel.bookmarkModel.folderName) + .focusedOnAppear() + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 14)) + ), + .init( + title: UserText.Bookmarks.Dialog.Field.location, + content: BookmarkDialogFolderManagementView( + folders: viewModel.bookmarkModel.folders, + selectedFolder: $viewModel.bookmarkModel.selectedFolder, + onActionButton: viewModel.addFolderAction + ) + ) + ) + }, + bottomSection: { + BookmarkDialogButtonsView( + viewState: .init(.compressed), + otherButtonAction: .init( + title: viewModel.bookmarkModel.cancelActionTitle, + isDisabled: viewModel.bookmarkModel.isOtherActionDisabled, + action: viewModel.bookmarkModel.cancel + ), + defaultButtonAction: .init( + title: viewModel.bookmarkModel.defaultActionTitle, + keyboardShortCut: .defaultAction, + isDisabled: viewModel.bookmarkModel.isDefaultActionDisabled, + action: viewModel.bookmarkModel.addOrSave + ) + ) + } + + ) + .frame(width: 448) + } + + private var addFolderView: some View { + AddEditBookmarkFolderView( + title: viewModel.folderModel.title, + buttonsState: .compressed, + folders: viewModel.folderModel.folders, + folderName: $viewModel.folderModel.folderName, + selectedFolder: $viewModel.folderModel.selectedFolder, + cancelActionTitle: viewModel.folderModel.cancelActionTitle, + isCancelActionDisabled: viewModel.folderModel.isOtherActionDisabled, + cancelAction: { _ in + viewModel.dismissAction() + }, + defaultActionTitle: viewModel.folderModel.defaultActionTitle, + isDefaultActionDisabled: viewModel.folderModel.isDefaultActionDisabled, + defaultAction: { _ in + viewModel.folderModel.addOrSave { + viewModel.dismissAction() + } + } + ) + .frame(width: 448, height: 210) + } +} + +#if DEBUG +#Preview("Bookmark All Tabs - Light") { + let parentFolder = BookmarkFolder(id: "7", title: "DuckDuckGo") + let bookmark = Bookmark(id: "1", url: "www.duckduckgo.com", title: "DuckDuckGo", isFavorite: true, parentFolderUUID: "7") + let bookmarkManager = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [bookmark, parentFolder])) + bookmarkManager.loadBookmarks() + let websitesInfo: [WebsiteInfo] = [ + .init(.init(content: .url(URL.duckDuckGo, credential: nil, source: .ui)))!, + .init(.init(content: .url(URL.duckDuckGoEmail, credential: nil, source: .ui)))!, + ] + + return BookmarksDialogViewFactory.makeBookmarkAllOpenTabsView(websitesInfo: websitesInfo, bookmarkManager: bookmarkManager) + .preferredColorScheme(.light) +} + +#Preview("Bookmark All Tabs - Dark") { + let parentFolder = BookmarkFolder(id: "7", title: "DuckDuckGo") + let bookmark = Bookmark(id: "1", url: "www.duckduckgo.com", title: "DuckDuckGo", isFavorite: true, parentFolderUUID: "7") + let bookmarkManager = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [bookmark, parentFolder])) + bookmarkManager.loadBookmarks() + let websitesInfo: [WebsiteInfo] = [ + .init(.init(content: .url(URL.duckDuckGo, credential: nil, source: .ui)))!, + .init(.init(content: .url(URL.duckDuckGoEmail, credential: nil, source: .ui)))!, + ] + + return BookmarksDialogViewFactory.makeBookmarkAllOpenTabsView(websitesInfo: websitesInfo, bookmarkManager: bookmarkManager) + .preferredColorScheme(.dark) +} +#endif diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogContainerView.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogContainerView.swift index ea49712abb..120869ea4f 100644 --- a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogContainerView.swift +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogContainerView.swift @@ -42,9 +42,13 @@ struct BookmarkDialogContainerView: View { Text(title) .foregroundColor(.primary) .fontWeight(.semibold) + .padding(.top, 20) }, center: middleSection, - bottom: bottomSection + bottom: { + bottomSection() + .padding(.bottom, 16.0) + } ) } } diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift index b29b50bbbb..3bff7ff4af 100644 --- a/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift @@ -80,6 +80,18 @@ enum BookmarksDialogViewFactory { return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) } + /// Creates an instance of AddEditBookmarkDialogView for adding Bookmarks for all the open Tabs. + /// - Parameters: + /// - websitesInfo: A list of websites to add as bookmarks. + /// - bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. + /// - Returns: An instance of BookmarkAllTabsDialogView + static func makeBookmarkAllOpenTabsView(websitesInfo: [WebsiteInfo], bookmarkManager: LocalBookmarkManager = .shared) -> BookmarkAllTabsDialogView { + let addFolderViewModel = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: nil), bookmarkManager: bookmarkManager) + let bookmarkAllTabsViewModel = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: UserDefaultsBookmarkFoldersStore(), bookmarkManager: bookmarkManager) + let viewModel = BookmarkAllTabsDialogCoordinatorViewModel(bookmarkModel: bookmarkAllTabsViewModel, folderModel: addFolderViewModel) + return BookmarkAllTabsDialogView(viewModel: viewModel) + } + } private extension BookmarksDialogViewFactory { diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkFolderDialogViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkFolderDialogViewModel.swift index 62c1e0356c..48815ebc8d 100644 --- a/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkFolderDialogViewModel.swift +++ b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkFolderDialogViewModel.swift @@ -42,8 +42,9 @@ final class AddEditBookmarkFolderDialogViewModel: BookmarkFolderDialogEditing { @Published var folderName: String @Published var selectedFolder: BookmarkFolder? + @Published private(set) var folders: [FolderViewModel] - let folders: [FolderViewModel] + private var folderCancellable: AnyCancellable? var title: String { mode.title @@ -77,14 +78,20 @@ final class AddEditBookmarkFolderDialogViewModel: BookmarkFolderDialogEditing { folderName = mode.folderName folders = .init(bookmarkManager.list) selectedFolder = mode.parentFolder + + bind() } func cancel(dismiss: () -> Void) { + reset() dismiss() } func addOrSave(dismiss: () -> Void) { - defer { dismiss() } + defer { + reset() + dismiss() + } guard !folderName.isEmpty else { assertionFailure("folderName is empty, button should be disabled") @@ -110,6 +117,14 @@ final class AddEditBookmarkFolderDialogViewModel: BookmarkFolderDialogEditing { private extension AddEditBookmarkFolderDialogViewModel { + func bind() { + folderCancellable = bookmarkManager.listPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] bookmarkList in + self?.folders = .init(bookmarkList) + }) + } + func update(folder: BookmarkFolder, originalParent: BookmarkFolder?, newParent: BookmarkFolder?) { // If the original location of the folder changed move it to the new folder. if selectedFolder?.id != originalParent?.id { @@ -129,6 +144,10 @@ private extension AddEditBookmarkFolderDialogViewModel { } } + func reset() { + self.folderName = "" + } + } // MARK: - AddEditBookmarkFolderDialogViewModel.Mode diff --git a/DuckDuckGo/Bookmarks/ViewModel/BookmarkAllTabsDialogCoordinatorViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/BookmarkAllTabsDialogCoordinatorViewModel.swift new file mode 100644 index 0000000000..975c8811b6 --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/BookmarkAllTabsDialogCoordinatorViewModel.swift @@ -0,0 +1,74 @@ +// +// BookmarkAllTabsDialogCoordinatorViewModel.swift +// +// Copyright © 2024 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 +import Combine + +final class BookmarkAllTabsDialogCoordinatorViewModel: ObservableObject { + @ObservedObject var bookmarkModel: BookmarkViewModel + @ObservedObject var folderModel: AddFolderViewModel + @Published var viewState: ViewState + + private var cancellables: Set = [] + + init(bookmarkModel: BookmarkViewModel, folderModel: AddFolderViewModel) { + self.bookmarkModel = bookmarkModel + self.folderModel = folderModel + viewState = .bookmarkAllTabs + bind() + } + + func dismissAction() { + viewState = .bookmarkAllTabs + } + + func addFolderAction() { + folderModel.selectedFolder = bookmarkModel.selectedFolder + viewState = .addFolder + } + + private func bind() { + bookmarkModel.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + folderModel.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + folderModel.addFolderPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] bookmarkFolder in + self?.bookmarkModel.selectedFolder = bookmarkFolder + } + .store(in: &cancellables) + } +} + +extension BookmarkAllTabsDialogCoordinatorViewModel { + enum ViewState { + case bookmarkAllTabs + case addFolder + } +} diff --git a/DuckDuckGo/Bookmarks/ViewModel/BookmarkAllTabsDialogViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/BookmarkAllTabsDialogViewModel.swift new file mode 100644 index 0000000000..daf6018403 --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/BookmarkAllTabsDialogViewModel.swift @@ -0,0 +1,127 @@ +// +// BookmarkAllTabsDialogViewModel.swift +// +// Copyright © 2024 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 Combine + +@MainActor +protocol BookmarkAllTabsDialogEditing: BookmarksDialogViewModel { + var folderName: String { get set } + var educationalMessage: String { get } + var folderNameFieldTitle: String { get } + var locationFieldTitle: String { get } +} + +final class BookmarkAllTabsDialogViewModel: BookmarkAllTabsDialogEditing { + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, .withDashSeparatorInDate] + return formatter + }() + + private let websites: [WebsiteInfo] + private let foldersStore: BookmarkFoldersStore + private let bookmarkManager: BookmarkManager + + private var folderCancellable: AnyCancellable? + + @Published private(set) var folders: [FolderViewModel] + @Published var selectedFolder: BookmarkFolder? + @Published var folderName: String + + var title: String { + String(format: UserText.Bookmarks.Dialog.Title.bookmarkOpenTabs, websites.count) + } + let cancelActionTitle = UserText.cancel + let defaultActionTitle = UserText.Bookmarks.Dialog.Action.addAllBookmarks + let educationalMessage = UserText.Bookmarks.Dialog.Message.bookmarkOpenTabsEducational + let folderNameFieldTitle = UserText.Bookmarks.Dialog.Field.folderName + let locationFieldTitle = UserText.Bookmarks.Dialog.Field.location + let isOtherActionDisabled = false + + var isDefaultActionDisabled: Bool { + folderName.trimmingWhitespace().isEmpty + } + + init( + websites: [WebsiteInfo], + foldersStore: BookmarkFoldersStore, + bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + dateFormatterConfigurationProvider: () -> DateFormatterConfiguration = DateFormatterConfiguration.defaultConfiguration + ) { + self.websites = websites + self.foldersStore = foldersStore + self.bookmarkManager = bookmarkManager + + folders = .init(bookmarkManager.list) + selectedFolder = foldersStore.lastBookmarkAllTabsFolderIdUsed.flatMap(bookmarkManager.getBookmarkFolder(withId:)) + folderName = Self.folderName(configuration: dateFormatterConfigurationProvider(), websitesNumber: websites.count) + bind() + } + + func cancel(dismiss: () -> Void) { + dismiss() + } + + func addOrSave(dismiss: () -> Void) { + // Save last used folder + foldersStore.lastBookmarkAllTabsFolderIdUsed = selectedFolder?.id + + // Save all bookmarks + let parentFolder: ParentFolderType = selectedFolder.flatMap { .parent(uuid: $0.id) } ?? .root + bookmarkManager.makeBookmarks(for: websites, inNewFolderNamed: folderName, withinParentFolder: parentFolder) + + // Dismiss the view + dismiss() + } +} + +// MARK: - Private + +private extension BookmarkAllTabsDialogViewModel { + + static func folderName(configuration: DateFormatterConfiguration, websitesNumber: Int) -> String { + Self.dateFormatter.timeZone = configuration.timeZone + let dateString = Self.dateFormatter.string(from: configuration.date) + return String(format: UserText.Bookmarks.Dialog.Value.folderName, dateString, websitesNumber) + } + + func bind() { + folderCancellable = bookmarkManager.listPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] bookmarkList in + self?.folders = .init(bookmarkList) + }) + } + +} + +// MARK: - DateConfiguration + +extension BookmarkAllTabsDialogViewModel { + + struct DateFormatterConfiguration { + let date: Date + let timeZone: TimeZone + + static func defaultConfiguration() -> DateFormatterConfiguration { + .init(date: Date(), timeZone: .current) + } + } + +} diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 4f57400e27..a201afff72 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -465,7 +465,8 @@ struct UserText { static let addFavorite = NSLocalizedString("add.favorite", value: "Add Favorite", comment: "Button for adding a favorite bookmark") static let editFavorite = NSLocalizedString("edit.favorite", value: "Edit Favorite", comment: "Header of the view that edits a favorite bookmark") static let removeFromFavorites = NSLocalizedString("remove.from.favorites", value: "Remove from Favorites", comment: "Button for removing bookmarks from favorites") - static let bookmarkThisPage = NSLocalizedString("bookmark.this.page", value: "Bookmark This Page", comment: "Menu item for bookmarking current page") + static let bookmarkThisPage = NSLocalizedString("bookmark.this.page", value: "Bookmark This Page…", comment: "Menu item for bookmarking current page") + static let bookmarkAllTabs = NSLocalizedString("bookmark.all.tabs", value: "Bookmark All Tabs…", comment: "Menu item for bookmarking all the open tabs") static let bookmarksShowToolbarPanel = NSLocalizedString("bookmarks.show-toolbar-panel", value: "Open Bookmarks Panel", comment: "Menu item for opening the bookmarks panel") static let bookmarksManageBookmarks = NSLocalizedString("bookmarks.manage-bookmarks", value: "Manage Bookmarks", comment: "Menu item for opening the bookmarks management interface") static let bookmarkImportedFromFolder = NSLocalizedString("bookmarks.imported.from.folder", value: "Imported from", comment: "Name of the folder the imported bookmarks are saved into") @@ -1105,15 +1106,24 @@ struct UserText { static let editBookmark = NSLocalizedString("bookmarks.dialog.title.edit", value: "Edit Bookmark", comment: "Bookmark edit dialog title") static let addFolder = NSLocalizedString("bookmarks.dialog.folder.title.add", value: "Add Folder", comment: "Bookmark folder creation dialog title") static let editFolder = NSLocalizedString("bookmarks.dialog.folder.title.edit", value: "Edit Folder", comment: "Bookmark folder edit dialog title") + static let bookmarkOpenTabs = NSLocalizedString("bookmarks.dialog.allTabs.title.add", value: "Bookmark Open Tabs (%d)", comment: "Title of dialog to bookmark all open tabs. E.g. 'Bookmark Open Tabs (42)'") + } + enum Message { + static let bookmarkOpenTabsEducational = NSLocalizedString("bookmarks.dialog.allTabs.message.add", value: "These bookmarks will be saved in a new folder:", comment: "Bookmark creation for all open tabs dialog title") } enum Field { static let name = NSLocalizedString("bookmarks.dialog.field.name", value: "Name", comment: "Name field label for Bookmark or Folder") static let url = NSLocalizedString("bookmarks.dialog.field.url", value: "URL", comment: "URL field label for Bookmar") static let location = NSLocalizedString("bookmarks.dialog.field.location", value: "Location", comment: "Location field label for Bookmark folder") + static let folderName = NSLocalizedString("bookmarks.dialog.field.folderName", value: "Folder Name", comment: "Folder name field label for Bookmarks folder") + } + enum Value { + static let folderName = NSLocalizedString("bookmarks.dialog.field.folderName.value", value: "%@ - Tabs (%d)", comment: "The suggested name of the folder that will contain the bookmark tabs. Eg. 2024-02-12 - Tabs (42)") } enum Action { static let addBookmark = NSLocalizedString("bookmarks.dialog.action.addBookmark", value: "Add Bookmark", comment: "CTA title for adding a Bookmark") static let addFolder = NSLocalizedString("bookmarks.dialog.action.addFolder", value: "Add Folder", comment: "CTA title for adding a Folder") + static let addAllBookmarks = NSLocalizedString("bookmarks.dialog.action.addAllBookmarks", value: "Save Bookmarks", comment: "CTA title for saving multiple Bookmarks at once") } } } diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 993f125ffd..03514781c1 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -7474,6 +7474,66 @@ } } }, + "bookmark.all.tabs" : { + "comment" : "Menu item for bookmarking all the open tabs", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Tabs mit Lesezeichen versehen…" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark All Tabs…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir todas las pestañas a marcadores…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter tous les onglets aux signets…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crea un segnalibro con tutte le schede…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle tabbladen toevoegen als bladwijzer…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj wszystkie karty do zakładek…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marcar todos os separadores…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить все вкладки в закладки…" + } + } + } + }, "bookmark.dialog.add" : { "comment" : "Button to confim a bookmark creation", "extractionState" : "extracted_with_value", @@ -7601,55 +7661,55 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Diese Seite als Lesezeichen markieren" + "value" : "Diese Seite mit einem Lesezeichen versehen…" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Bookmark This Page" + "value" : "Bookmark This Page…" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Marcar esta página" + "value" : "Marcar esta página…" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ajouter cette page aux signets" + "value" : "Ajouter cette page aux signets…" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Crea un segnalibro per questa pagina" + "value" : "Crea un segnalibro per questa pagina…" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bladwijzer toevoegen aan deze pagina" + "value" : "Bladwijzer toevoegen voor deze pagina…" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Dodaj tę stronę do zakładek" + "value" : "Dodaj tę stronę do zakładek…" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Marcar esta página" + "value" : "Marcar esta página…" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Сохранить в закладках" + "value" : "Сохранить страницу в закладках…" } } } @@ -8660,6 +8720,66 @@ } } }, + "bookmarks.dialog.action.addAllBookmarks" : { + "comment" : "CTA title for saving multiple Bookmarks at once", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lesezeichen speichern" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save Bookmarks" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar marcadores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer les signets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva segnalibri" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bladwijzers opslaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz zakładki" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar marcadores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранение закладок" + } + } + } + }, "bookmarks.dialog.action.addBookmark" : { "comment" : "CTA title for adding a Bookmark", "extractionState" : "extracted_with_value", @@ -8780,6 +8900,246 @@ } } }, + "bookmarks.dialog.allTabs.message.add" : { + "comment" : "Bookmark creation for all open tabs dialog title", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Lesezeichen werden in einem neuen Ordner gespeichert:" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "These bookmarks will be saved in a new folder:" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estos marcadores se guardarán en una nueva carpeta:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ces signets seront enregistrés dans un nouveau dossier:" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questi segnalibri verranno salvati in una nuova cartella:" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deze bladwijzers worden opgeslagen in een nieuwe map:" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Te zakładki zostaną zapisane w nowym folderze:" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estes marcadores serão guardados numa nova pasta:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эти закладки будут сохранены в новой папке:" + } + } + } + }, + "bookmarks.dialog.allTabs.title.add" : { + "comment" : "Title of dialog to bookmark all open tabs. E.g. 'Bookmark Open Tabs (42)'", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offene Tabs mit Lesezeichen versehen (%d)" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark Open Tabs (%d)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir pestañas abiertas (%d)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter les onglets ouverts (%d) aux signets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungere ai segnalibri le schede aperte (%d)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d geopende tabbladen toevoegen als bladwijzer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj otwarte karty (%d) do zakładek" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marcar separadores abertos (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить открытые вкладки (%d) в закладки" + } + } + } + }, + "bookmarks.dialog.field.folderName" : { + "comment" : "Folder name field label for Bookmarks folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordnername" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Folder Name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre de la carpeta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom du dossier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome della cartella" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naam van de map" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa folderu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome da pasta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название папки" + } + } + } + }, + "bookmarks.dialog.field.folderName.value" : { + "comment" : "The suggested name of the folder that will contain the bookmark tabs. Eg. 2024-02-12 - Tabs (42)", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – Tabs (%2$d)" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ - Tabs (%2$d)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - Pestañas (%2$d)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - Onglets (%2$d)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - Schede (%2$d)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - Tabbladen (%2$d)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - Karty (%2$d)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - Separadores (%2$d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - Вкладки (%2$d)" + } + } + } + }, "bookmarks.dialog.field.location" : { "comment" : "Location field label for Bookmark folder", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 132e25075d..66e026dc1d 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -291,6 +291,7 @@ import SubscriptionUI func buildBookmarksMenu() -> NSMenuItem { NSMenuItem(title: UserText.bookmarks).submenu(bookmarksMenu.buildItems { NSMenuItem(title: UserText.bookmarkThisPage, action: #selector(MainViewController.bookmarkThisPage), keyEquivalent: "d") + NSMenuItem(title: UserText.bookmarkAllTabs, action: #selector(MainViewController.bookmarkAllOpenTabs), keyEquivalent: [.command, .shift, "d"]) manageBookmarksMenuItem bookmarksMenuToggleBookmarksBarMenuItem NSMenuItem.separator() @@ -557,6 +558,14 @@ import SubscriptionUI let debugMenu = NSMenu(title: "Debug") { NSMenuItem(title: "Open Vanilla Browser", action: #selector(MainViewController.openVanillaBrowser)).withAccessibilityIdentifier("MainMenu.openVanillaBrowser") NSMenuItem.separator() + NSMenuItem(title: "Tab") { + NSMenuItem(title: "Append Tabs") { + NSMenuItem(title: "10 Tabs", action: #selector(MainViewController.addDebugTabs(_:)), representedObject: 10) + NSMenuItem(title: "50 Tabs", action: #selector(MainViewController.addDebugTabs(_:)), representedObject: 50) + NSMenuItem(title: "100 Tabs", action: #selector(MainViewController.addDebugTabs(_:)), representedObject: 100) + NSMenuItem(title: "150 Tabs", action: #selector(MainViewController.addDebugTabs(_:)), representedObject: 150) + } + } NSMenuItem(title: "Reset Data") { NSMenuItem(title: "Reset Default Browser Prompt", action: #selector(MainViewController.resetDefaultBrowserPrompt)) NSMenuItem(title: "Reset Default Grammar Checks", action: #selector(MainViewController.resetDefaultGrammarChecks)) diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 59729584ac..ec129e39df 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -507,6 +507,11 @@ extension MainViewController { .openBookmarkPopover(setFavorite: false, accessPoint: .init(sender: sender, default: .moreMenu)) } + @objc func bookmarkAllOpenTabs(_ sender: Any) { + let websitesInfo = tabCollectionViewModel.tabs.compactMap(WebsiteInfo.init) + BookmarksDialogViewFactory.makeBookmarkAllOpenTabsView(websitesInfo: websitesInfo).show() + } + @objc func favoriteThisPage(_ sender: Any) { guard let tabIndex = getActiveTabAndIndex()?.index else { return } if tabCollectionViewModel.selectedTabIndex != tabIndex { @@ -656,6 +661,14 @@ extension MainViewController { // MARK: - Debug + @objc func addDebugTabs(_ sender: AnyObject) { + let numberOfTabs = sender.representedObject as? Int ?? 1 + (1...numberOfTabs).forEach { _ in + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .ui)) + tabCollectionViewModel.append(tab: tab) + } + } + @objc func resetDefaultBrowserPrompt(_ sender: Any?) { UserDefaultsWrapper.clear(.defaultBrowserDismissed) } @@ -944,6 +957,8 @@ extension MainViewController: NSMenuItemValidation { case #selector(MainViewController.bookmarkThisPage(_:)), #selector(MainViewController.favoriteThisPage(_:)): return activeTabViewModel?.canBeBookmarked == true + case #selector(MainViewController.bookmarkAllOpenTabs(_:)): + return tabCollectionViewModel.canBookmarkAllOpenTabs() case #selector(MainViewController.openBookmark(_:)), #selector(MainViewController.showManageBookmarks(_:)): return true diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 2638bc96bb..ed7bb58e86 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -28,6 +28,7 @@ import Subscription protocol OptionsButtonMenuDelegate: AnyObject { func optionsButtonMenuRequestedBookmarkThisPage(_ sender: NSMenuItem) + func optionsButtonMenuRequestedBookmarkAllOpenTabs(_ sender: NSMenuItem) func optionsButtonMenuRequestedBookmarkPopover(_ menu: NSMenu) func optionsButtonMenuRequestedBookmarkManagementInterface(_ menu: NSMenu) func optionsButtonMenuRequestedBookmarkImportInterface(_ menu: NSMenu) @@ -171,6 +172,10 @@ final class MoreOptionsMenu: NSMenu { actionDelegate?.optionsButtonMenuRequestedBookmarkThisPage(sender) } + @objc func bookmarkAllOpenTabs(_ sender: NSMenuItem) { + actionDelegate?.optionsButtonMenuRequestedBookmarkAllOpenTabs(sender) + } + @objc func openBookmarks(_ sender: NSMenuItem) { actionDelegate?.optionsButtonMenuRequestedBookmarkPopover(self) } @@ -627,6 +632,12 @@ final class BookmarksSubMenu: NSMenu { bookmarkPageItem.isEnabled = tabCollectionViewModel.selectedTabViewModel?.canBeBookmarked == true + let bookmarkAllTabsItem = addItem(withTitle: UserText.bookmarkAllTabs, action: #selector(MoreOptionsMenu.bookmarkAllOpenTabs(_:)), keyEquivalent: "d") + .withModifierMask([.command, .shift]) + .targetting(target) + + bookmarkAllTabsItem.isEnabled = tabCollectionViewModel.canBookmarkAllOpenTabs() + addItem(NSMenuItem.separator()) addItem(withTitle: UserText.bookmarksShowToolbarPanel, action: #selector(MoreOptionsMenu.openBookmarks(_:)), keyEquivalent: "") diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index d27b62fd5d..dc181f12b1 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -1011,6 +1011,11 @@ extension NavigationBarViewController: OptionsButtonMenuDelegate { .openBookmarkPopover(setFavorite: false, accessPoint: .init(sender: sender, default: .moreMenu)) } + func optionsButtonMenuRequestedBookmarkAllOpenTabs(_ sender: NSMenuItem) { + let websitesInfo = tabCollectionViewModel.tabs.compactMap(WebsiteInfo.init) + BookmarksDialogViewFactory.makeBookmarkAllOpenTabsView(websitesInfo: websitesInfo).show() + } + func optionsButtonMenuRequestedBookmarkPopover(_ menu: NSMenu) { popovers.showBookmarkListPopover(usingView: bookmarkListButton, withDelegate: self, diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index 708d25fbe4..e2eeb8d192 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -727,7 +727,6 @@ enum GeneralPixel: PixelKitEventV2 { case .bookmarksMigrationCouldNotRemoveOldStore: return "bookmarks_migration_could_not_remove_old_store" case .bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders: return "bookmarks_migration_could_not_prepare_multiple_favorite_folders" - case .syncSentUnauthenticatedRequest: return "sync_sent_unauthenticated_request" case .syncMetadataCouldNotLoadDatabase: return "sync_metadata_could_not_load_database" case .syncBookmarksProviderInitializationFailed: return "sync_bookmarks_provider_initialization_failed" diff --git a/DuckDuckGo/TabBar/View/TabBarViewController.swift b/DuckDuckGo/TabBar/View/TabBarViewController.swift index 8f0253224b..1fe33c029c 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewController.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewController.swift @@ -1049,6 +1049,15 @@ extension TabBarViewController: TabBarViewItemDelegate { bookmarkTab(with: url, title: tabViewModel.title) } + func tabBarViewAllItemsCanBeBookmarked(_ tabBarViewItem: TabBarViewItem) -> Bool { + tabCollectionViewModel.canBookmarkAllOpenTabs() + } + + func tabBarViewItemBookmarkAllOpenTabsAction(_ tabBarViewItem: TabBarViewItem) { + let websitesInfo = tabCollectionViewModel.tabs.compactMap(WebsiteInfo.init) + BookmarksDialogViewFactory.makeBookmarkAllOpenTabsView(websitesInfo: websitesInfo).show() + } + func tabBarViewItemCloseAction(_ tabBarViewItem: TabBarViewItem) { guard let indexPath = collectionView.indexPath(for: tabBarViewItem) else { assertionFailure("TabBarViewController: Failed to get index path of tab bar view item") diff --git a/DuckDuckGo/TabBar/View/TabBarViewItem.swift b/DuckDuckGo/TabBar/View/TabBarViewItem.swift index a92752aa56..02b3ab4af5 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewItem.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewItem.swift @@ -33,6 +33,7 @@ protocol TabBarViewItemDelegate: AnyObject { func tabBarViewItemCanBeDuplicated(_ tabBarViewItem: TabBarViewItem) -> Bool func tabBarViewItemCanBePinned(_ tabBarViewItem: TabBarViewItem) -> Bool func tabBarViewItemCanBeBookmarked(_ tabBarViewItem: TabBarViewItem) -> Bool + func tabBarViewAllItemsCanBeBookmarked(_ tabBarViewItem: TabBarViewItem) -> Bool func tabBarViewItemCloseAction(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemTogglePermissionAction(_ tabBarViewItem: TabBarViewItem) @@ -41,6 +42,7 @@ protocol TabBarViewItemDelegate: AnyObject { func tabBarViewItemDuplicateAction(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemPinAction(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemBookmarkThisPageAction(_ tabBarViewItem: TabBarViewItem) + func tabBarViewItemBookmarkAllOpenTabsAction(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemMoveToNewWindowAction(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemMoveToNewBurnerWindowAction(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemFireproofSite(_ tabBarViewItem: TabBarViewItem) @@ -196,6 +198,10 @@ final class TabBarViewItem: NSCollectionViewItem { delegate?.tabBarViewItemBookmarkThisPageAction(self) } + @objc func bookmarkAllOpenTabsAction(_ sender: Any) { + delegate?.tabBarViewItemBookmarkAllOpenTabsAction(self) + } + private var lastKnownIndexPath: IndexPath? @IBAction func closeButtonAction(_ sender: Any) { @@ -486,16 +492,19 @@ extension TabBarViewItem: NSMenuDelegate { // Section 1 addDuplicateMenuItem(to: menu) addPinMenuItem(to: menu) - menu.addItem(NSMenuItem.separator()) + addMuteUnmuteMenuItem(to: menu) + menu.addItem(.separator()) // Section 2 - addBookmarkMenuItem(to: menu) addFireproofMenuItem(to: menu) - - addMuteUnmuteMenuItem(to: menu) - menu.addItem(NSMenuItem.separator()) + addBookmarkMenuItem(to: menu) + menu.addItem(.separator()) // Section 3 + addBookmarkAllTabsMenuItem(to: menu) + menu.addItem(.separator()) + + // Section 4 addCloseMenuItem(to: menu) addCloseOtherMenuItem(to: menu, areThereOtherTabs: areThereOtherTabs) addCloseTabsToTheRightMenuItem(to: menu, areThereTabsToTheRight: otherItemsState.hasItemsToTheRight) @@ -525,6 +534,13 @@ extension TabBarViewItem: NSMenuDelegate { menu.addItem(bookmarkMenuItem) } + private func addBookmarkAllTabsMenuItem(to menu: NSMenu) { + let bookmarkMenuItem = NSMenuItem(title: UserText.bookmarkAllTabs, action: #selector(bookmarkAllOpenTabsAction(_:)), keyEquivalent: "") + bookmarkMenuItem.target = self + bookmarkMenuItem.isEnabled = delegate?.tabBarViewAllItemsCanBeBookmarked(self) ?? false + menu.addItem(bookmarkMenuItem) + } + private func addFireproofMenuItem(to menu: NSMenu) { var menuItem = NSMenuItem(title: UserText.fireproofSite, action: #selector(fireproofSiteAction(_:)), keyEquivalent: "") menuItem.isEnabled = false @@ -542,7 +558,6 @@ extension TabBarViewItem: NSMenuDelegate { private func addMuteUnmuteMenuItem(to menu: NSMenu) { guard let audioState = delegate?.tabBarViewItemAudioState(self) else { return } - menu.addItem(NSMenuItem.separator()) let menuItemTitle = audioState == .muted ? UserText.unmuteTab : UserText.muteTab let muteUnmuteMenuItem = NSMenuItem(title: menuItemTitle, action: #selector(muteUnmuteSiteAction(_:)), keyEquivalent: "") muteUnmuteMenuItem.target = self diff --git a/DuckDuckGo/TabBar/ViewModel/TabCollectionViewModel.swift b/DuckDuckGo/TabBar/ViewModel/TabCollectionViewModel.swift index 0317aed9b9..2467c1d900 100644 --- a/DuckDuckGo/TabBar/ViewModel/TabCollectionViewModel.swift +++ b/DuckDuckGo/TabBar/ViewModel/TabCollectionViewModel.swift @@ -763,3 +763,14 @@ extension TabCollectionViewModel { } } + +// MARK: - Bookmark All Open Tabs + +extension TabCollectionViewModel { + + func canBookmarkAllOpenTabs() -> Bool { + // At least two non pinned, non empty (URL only), and not showing an error tabs. + tabViewModels.values.filter(\.canBeBookmarked).count >= 2 + } + +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/PromptActionView/PromptActionView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/PromptActionView/PromptActionView.swift index ab497e050b..372f25aa52 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/PromptActionView/PromptActionView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/PromptActionView/PromptActionView.swift @@ -18,6 +18,7 @@ import Foundation import SwiftUI +import SwiftUIExtensions fileprivate extension View { func applyStepTitleAttributes() -> some View { diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MultilineTextHeightFixer.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/MultilineTextHeightFixer.swift similarity index 98% rename from LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MultilineTextHeightFixer.swift rename to LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/MultilineTextHeightFixer.swift index 43b81b54c9..9a6e693e43 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MultilineTextHeightFixer.swift +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/MultilineTextHeightFixer.swift @@ -39,7 +39,7 @@ private struct MultilineTextHeightFixer: ViewModifier { } } -extension View { +public extension View { /// Meant to be used for multiline-text. This is currently only applying a modifier /// diff --git a/UnitTests/Bookmarks/Helpers/WebsiteInfoHelpers.swift b/UnitTests/Bookmarks/Helpers/WebsiteInfoHelpers.swift new file mode 100644 index 0000000000..9a87f91090 --- /dev/null +++ b/UnitTests/Bookmarks/Helpers/WebsiteInfoHelpers.swift @@ -0,0 +1,35 @@ +// +// WebsiteInfoHelpers.swift +// +// Copyright © 2024 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 +@testable import DuckDuckGo_Privacy_Browser + +extension WebsiteInfo { + + @MainActor + static func makeWebsitesInfo(url: URL, title: String? = nil, occurrences: Int = 1) -> [WebsiteInfo] { + (1...occurrences) + .map { _ in + let tab = Tab(content: .url(url, credential: nil, source: .ui)) + tab.title = title + return tab + } + .compactMap(WebsiteInfo.init) + } + +} diff --git a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift index 97ce23202d..79d3809847 100644 --- a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift +++ b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift @@ -178,6 +178,87 @@ final class LocalBookmarkManagerTests: XCTestCase { XCTAssertNotNil(bookmarkList) } + func testWhenGetBookmarkFolderIsCalledThenAskBookmarkStoreToRetrieveFolder() throws { + // GIVEN + let (bookmarkManager, bookmarkStoreMock) = LocalBookmarkManager.aManager + XCTAssertFalse(bookmarkStoreMock.bookmarkFolderWithIdCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolderId) + + // WHEN + _ = bookmarkManager.getBookmarkFolder(withId: #function) + + // THEN + XCTAssertTrue(bookmarkStoreMock.bookmarkFolderWithIdCalled) + XCTAssertEqual(bookmarkStoreMock.capturedFolderId, #function) + } + + func testWhenGetBookmarkFolderIsCalledAndFolderExistsInStoreThenBookmarkStoreReturnsFolder() throws { + // GIVEN + let (bookmarkManager, bookmarkStoreMock) = LocalBookmarkManager.aManager + let folder = BookmarkFolder(id: "1", title: "Test") + bookmarkStoreMock.bookmarkFolder = folder + + // WHEN + let result = bookmarkManager.getBookmarkFolder(withId: #function) + + // THEN + XCTAssertEqual(result, folder) + } + + func testWhenGetBookmarkFolderIsCalledAndFolderDoesNotExistInStoreThenBookmarkStoreReturnsNil() throws { + // GIVEN + let (bookmarkManager, bookmarkStoreMock) = LocalBookmarkManager.aManager + bookmarkStoreMock.bookmarkFolder = nil + + // WHEN + let result = bookmarkManager.getBookmarkFolder(withId: #function) + + // THEN + XCTAssertNil(result) + } + + // MARK: - Save Multiple Bookmarks at once + + func testWhenMakeBookmarksForWebsitesInfoIsCalledThenBookmarkStoreIsAskedToCreateMultipleBookmarks() { + // GIVEN + let (sut, bookmarkStoreMock) = LocalBookmarkManager.aManager + let newFolderName = #function + let websitesInfo = [ + WebsiteInfo(url: URL.duckDuckGo, title: "Website 1"), + WebsiteInfo(url: URL.duckDuckGo, title: "Website 2"), + WebsiteInfo(url: URL.duckDuckGo, title: "Website 3"), + WebsiteInfo(url: URL.duckDuckGo, title: "Website 4"), + ].compactMap { $0 } + XCTAssertFalse(bookmarkStoreMock.saveBookmarksInNewFolderNamedCalled) + XCTAssertNil(bookmarkStoreMock.capturedWebsitesInfo) + XCTAssertNil(bookmarkStoreMock.capturedNewFolderName) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.makeBookmarks(for: websitesInfo, inNewFolderNamed: newFolderName, withinParentFolder: .root) + + // THEN + XCTAssertTrue(bookmarkStoreMock.saveBookmarksInNewFolderNamedCalled) + XCTAssertEqual(bookmarkStoreMock.capturedWebsitesInfo?.count, 4) + XCTAssertEqual(bookmarkStoreMock.capturedWebsitesInfo, websitesInfo) + XCTAssertEqual(bookmarkStoreMock.capturedNewFolderName, newFolderName) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .root) + } + + func testWhenMakeBookmarksForWebsiteInfoIsCalledThenReloadAllBookmarks() { + // GIVEN + let (sut, bookmarkStoreMock) = LocalBookmarkManager.aManager + bookmarkStoreMock.loadAllCalled = false // Reset after load all bookmarks the first time + XCTAssertFalse(bookmarkStoreMock.loadAllCalled) + let websitesInfo = [WebsiteInfo(url: URL.duckDuckGo, title: "Website 1")].compactMap { $0 } + + // WHEN + sut.makeBookmarks(for: websitesInfo, inNewFolderNamed: "Test", withinParentFolder: .root) + + // THEN + XCTAssertTrue(bookmarkStoreMock.loadAllCalled) + } + } fileprivate extension LocalBookmarkManager { @@ -204,3 +285,14 @@ fileprivate extension Bookmark { isFavorite: false) } + +private extension WebsiteInfo { + + @MainActor + init?(url: URL, title: String) { + let tab = Tab(content: .url(url, credential: nil, source: .ui)) + tab.title = title + self.init(tab) + } + +} diff --git a/UnitTests/Bookmarks/Model/WebsiteInfoTests.swift b/UnitTests/Bookmarks/Model/WebsiteInfoTests.swift new file mode 100644 index 0000000000..7b1b8e8196 --- /dev/null +++ b/UnitTests/Bookmarks/Model/WebsiteInfoTests.swift @@ -0,0 +1,77 @@ +// +// WebsiteInfoTests.swift +// +// Copyright © 2024 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +@MainActor +final class WebsiteInfoTests: XCTestCase { + + // MARK: - URL + + func testWhenInitWithTabThenSetURLWithTabURLValue() throws { + // GIVEN + let url = URL.duckDuckGo + let websiteInfo = try XCTUnwrap(WebsiteInfo.makeWebsitesInfo(url: url).first) + + // WHEN + let result = websiteInfo.url + + // THEN + XCTAssertEqual(result, url) + } + + // MARK: - Title + + func testWhenTitleIsNotNilThenDisplayTitleReturnsTitleValue() throws { + // GIVEN + let title = #function + let websiteInfo = try XCTUnwrap(WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, title: title).first) + + // WHEN + let result = websiteInfo.title + + // THEN + XCTAssertEqual(result, title) + } + + func testWhenTitleIsNilAndURLConformsToRFC3986ThenDisplayTitleReturnsURLHost() throws { + // GIVEN + let url = URL.duckDuckGo + let websiteInfo = try XCTUnwrap(WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, title: nil).first) + + // WHEN + let result = websiteInfo.title + + // THEN + XCTAssertEqual(result, url.host) + } + + func testWhenTitleIsNilAndURLDoesNotConformToRFC3986ThenDisplayTitleReturnsURLAbsoluteString() throws { + // GIVEN + let invalidURL = try XCTUnwrap(URL(string: "duckduckgo.com")) + let websiteInfo = try XCTUnwrap(WebsiteInfo.makeWebsitesInfo(url: invalidURL, title: nil).first) + + // WHEN + let result = websiteInfo.title + + // THEN + XCTAssertEqual(result, invalidURL.absoluteString) + } + +} diff --git a/UnitTests/Bookmarks/Services/BookmarkFolderStoreMock.swift b/UnitTests/Bookmarks/Services/BookmarkFolderStoreMock.swift new file mode 100644 index 0000000000..014282eff6 --- /dev/null +++ b/UnitTests/Bookmarks/Services/BookmarkFolderStoreMock.swift @@ -0,0 +1,25 @@ +// +// BookmarkFolderStoreMock.swift +// +// Copyright © 2024 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 +@testable import DuckDuckGo_Privacy_Browser + +final class BookmarkFolderStoreMock: BookmarkFoldersStore { + var lastBookmarkAllTabsFolderIdUsed: String? + +} diff --git a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift index 2ebff62e7d..73d2f96e8d 100644 --- a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift +++ b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift @@ -261,6 +261,134 @@ final class LocalBookmarkStoreTests: XCTestCase { waitForExpectations(timeout: 3, handler: nil) } + @MainActor + func testWhenSaveMultipleWebsiteInfoToANewFolderInRootFolder_ThenTheNewFolderIsCreated_AndBoomarksAreAddedToTheFolder() async throws { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let newFolderName = "Bookmark All Open Tabs" + let websites = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, occurrences: 50) + var bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + var topLevelEntities = try await sut.loadAll(type: .topLevelEntities).get() + XCTAssertEqual(bookmarksEntity.count, 0) + XCTAssertEqual(topLevelEntities.count, 0) + + // WHEN + sut.saveBookmarks(for: websites, inNewFolderNamed: newFolderName, withinParentFolder: .root) + + // THEN + bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + topLevelEntities = try await sut.loadAll(type: .topLevelEntities).get() + let bookmarks = try XCTUnwrap(bookmarksEntity as? [Bookmark]) + let folders = try XCTUnwrap(topLevelEntities as? [BookmarkFolder]) + let folder = try XCTUnwrap(folders.first) + XCTAssertEqual(bookmarksEntity.count, 50) + XCTAssertEqual(folders.count, 1) + XCTAssertEqual(folder.parentFolderUUID, BookmarkEntity.Constants.rootFolderID) + XCTAssertEqual(folder.title, newFolderName) + XCTAssertEqual(Set(folder.children), Set(bookmarks)) + bookmarks.forEach { bookmark in + XCTAssertEqual(bookmark.parentFolderUUID, folder.id) + } + } + + @MainActor + func testWhenSaveMultipleWebsiteInfoToANewFolderInSubfolder_ThenTheNewFolderIsCreated_AndBoomarksAreAddedToTheFolder() async throws { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let newFolderName = "Bookmark All Open Tabs" + let websites = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, occurrences: 50) + let parentFolderToInsert = BookmarkFolder(id: "ABCDE", title: "Subfolder") + _ = await sut.save(folder: parentFolderToInsert, parent: nil) + var bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + var topLevelEntities = try await sut.loadAll(type: .topLevelEntities).get() + XCTAssertEqual(bookmarksEntity.count, 0) + XCTAssertEqual(topLevelEntities.count, 1) + XCTAssertEqual(topLevelEntities.first, parentFolderToInsert) + XCTAssertEqual((topLevelEntities.first as? BookmarkFolder)?.parentFolderUUID, BookmarkEntity.Constants.rootFolderID) + + // WHEN + sut.saveBookmarks(for: websites, inNewFolderNamed: newFolderName, withinParentFolder: .parent(uuid: parentFolderToInsert.id)) + + // THEN + bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + topLevelEntities = try await sut.loadAll(type: .topLevelEntities).get() + let bookmarks = try XCTUnwrap(bookmarksEntity as? [Bookmark]) + let folders = try XCTUnwrap(topLevelEntities as? [BookmarkFolder]) + let parentFolder = try XCTUnwrap(folders.first) + let subFolder = try XCTUnwrap(parentFolder.children.first as? BookmarkFolder) + XCTAssertEqual(bookmarksEntity.count, 50) + XCTAssertEqual(folders.count, 1) + XCTAssertEqual(parentFolder.title, parentFolderToInsert.title) + XCTAssertEqual(parentFolder.children.count, 1) + XCTAssertEqual(subFolder.title, newFolderName) + XCTAssertEqual(Set(subFolder.children), Set(bookmarks)) + bookmarks.forEach { bookmark in + XCTAssertEqual(bookmark.parentFolderUUID, subFolder.id) + } + } + + @MainActor + func testWhenSaveMultipleWebsiteInfo_AndTitleIsNotNil_ThenTitleIsUsedAsBookmarkTitle() async throws { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let websiteName = "Test Website" + let websites = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, title: websiteName, occurrences: 1) + var bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + XCTAssertEqual(bookmarksEntity.count, 0) + + // WHEN + sut.saveBookmarks(for: websites, inNewFolderNamed: "Saved Tabs", withinParentFolder: .root) + + // THEN + bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + let bookmark = try XCTUnwrap((bookmarksEntity as? [Bookmark])?.first) + XCTAssertEqual(bookmarksEntity.count, 1) + XCTAssertEqual(bookmark.title, websiteName) + } + + @MainActor + func testWhenSaveMultipleWebsiteInfo_AndTitleIsNil_ThenURLDomainIsUsedAsBookmarkTitle() async throws { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let url = URL.duckDuckGo + let websites = WebsiteInfo.makeWebsitesInfo(url: url, title: nil, occurrences: 1) + var bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + XCTAssertEqual(bookmarksEntity.count, 0) + + // WHEN + sut.saveBookmarks(for: websites, inNewFolderNamed: "Saved Tabs", withinParentFolder: .root) + + // THEN + bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + let bookmark = try XCTUnwrap((bookmarksEntity as? [Bookmark])?.first) + XCTAssertEqual(bookmarksEntity.count, 1) + XCTAssertEqual(bookmark.title, url.host) + } + + @MainActor + func testWhenSaveMultipleWebsiteInfo_AndTitleIsNil_AndURLDoesNotConformToRFC3986_ThenURLAbsoluteStringIsUsedAsBookmarkTitle() async throws { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let url = try XCTUnwrap(URL(string: "duckduckgo.com")) + let websites = WebsiteInfo.makeWebsitesInfo(url: url, title: nil, occurrences: 1) + var bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + XCTAssertEqual(bookmarksEntity.count, 0) + + // WHEN + sut.saveBookmarks(for: websites, inNewFolderNamed: "Saved Tabs", withinParentFolder: .root) + + // THEN + bookmarksEntity = try await sut.loadAll(type: .bookmarks).get() + let bookmark = try XCTUnwrap((bookmarksEntity as? [Bookmark])?.first) + XCTAssertEqual(bookmarksEntity.count, 1) + XCTAssertEqual(bookmark.title, url.absoluteString) + } + // MARK: Moving Bookmarks/Folders func testWhenMovingBookmarkWithinParentCollection_AndIndexIsValid_ThenBookmarkIsMoved() async { @@ -1071,6 +1199,63 @@ final class LocalBookmarkStoreTests: XCTestCase { } } + // MARK: - Retrieve Bookmark Folder + + func testWhenFetchingBookmarkFolderWithId_AndFolderExist_ThenFolderIsReturned() async { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let folderId = "ABCDE" + let folder = BookmarkFolder(id: folderId, title: "Test") + _ = await sut.save(folder: folder, parent: nil) + + // WHEN + let result = sut.bookmarkFolder(withId: folderId) + + // THEN + XCTAssertEqual(result, folder) + } + + func testWhenFetchingBookmarkFolderWithId_AndFolderDoesNotExist_ThenNilIsReturned() { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let folderId = "ABCDE" + + // WHEN + let result = sut.bookmarkFolder(withId: folderId) + + // THEN + XCTAssertNil(result) + } + + func testWhenFetchingBookmarkFolderWithId_AndFolderHasBeenMoved_ThenFolderIsStillReturned() async { + // GIVEN + let context = container.viewContext + let sut = LocalBookmarkStore(context: context) + let folderId = "ABCDE" + let folder1 = BookmarkFolder(id: UUID().uuidString, title: "Test") + let folder2 = BookmarkFolder(id: folderId, title: "Test") + let expectedFolder = BookmarkFolder(id: folderId, title: "Test", parentFolderUUID: folder1.id) + _ = await sut.save(folder: folder1, parent: nil) + _ = await sut.save(folder: folder2, parent: nil) + + // WHEN + let firstFetchResult = sut.bookmarkFolder(withId: folderId) + + // THEN + XCTAssertEqual(firstFetchResult, folder2) + + // Move folder + _ = await sut.move(objectUUIDs: [folder2.id], toIndex: nil, withinParentFolder: .parent(uuid: folder1.id)) + + // WHEN + let secondFetchResult = sut.bookmarkFolder(withId: folderId) + + // THEN + XCTAssertEqual(secondFetchResult, expectedFolder) + } + // MARK: Import func testWhenBookmarksAreImported_AndNoDuplicatesExist_ThenBookmarksAreImported() { diff --git a/UnitTests/Bookmarks/Services/UserDefaultsBookmarkFoldersStoreTests.swift b/UnitTests/Bookmarks/Services/UserDefaultsBookmarkFoldersStoreTests.swift new file mode 100644 index 0000000000..7020d8ec28 --- /dev/null +++ b/UnitTests/Bookmarks/Services/UserDefaultsBookmarkFoldersStoreTests.swift @@ -0,0 +1,62 @@ +// +// UserDefaultsBookmarkFoldersStoreTests.swift +// +// Copyright © 2024 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class UserDefaultsBookmarkFoldersStoreTests: XCTestCase { + private static let suiteName = "testing_bookmark_folders_store" + private var userDefaults: UserDefaults! + private var sut: UserDefaultsBookmarkFoldersStore! + + override func setUpWithError() throws { + try super.setUpWithError() + userDefaults = UserDefaults(suiteName: Self.suiteName) + sut = UserDefaultsBookmarkFoldersStore(userDefaults: userDefaults) + } + + override func tearDownWithError() throws { + userDefaults.removePersistentDomain(forName: Self.suiteName) + userDefaults = nil + sut = nil + try super.tearDownWithError() + } + + func testReturnBookmarkAllTabsLastFolderIdUsedWhenUserDefaultsContainsValue() { + // GIVEN + let value = "12345" + userDefaults.set(value, forKey: UserDefaultsBookmarkFoldersStore.Keys.bookmarkAllTabsFolderUsedKey) + + // WHEN + let result = sut.lastBookmarkAllTabsFolderIdUsed + + // THEN + XCTAssertEqual(result, value) + } + + func testReturnNilForBookmarkAllTabsLastFolderIdUsedWhenUserDefaultsDoesNotContainValue() { + // GIVEN + userDefaults.set(nil, forKey: UserDefaultsBookmarkFoldersStore.Keys.bookmarkAllTabsFolderUsedKey) + + // WHEN + let result = sut.lastBookmarkAllTabsFolderIdUsed + + // THEN + XCTAssertNil(result) + } +} diff --git a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogCoordinatorViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogCoordinatorViewModelTests.swift index 53072513c4..c8b101e148 100644 --- a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogCoordinatorViewModelTests.swift +++ b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogCoordinatorViewModelTests.swift @@ -125,46 +125,44 @@ final class AddEditBookmarkDialogCoordinatorViewModelTests: XCTestCase { XCTAssertEqual(bookmarkViewModelMock.selectedFolder, folder) } -} + // MARK: - Integration Test -final class AddEditBookmarkDialogViewModelMock: BookmarkDialogEditing { - var bookmarkName: String = "" - var bookmarkURLPath: String = "" - var isBookmarkFavorite: Bool = false - var isURLFieldHidden: Bool = false - var title: String = "" - var folders: [DuckDuckGo_Privacy_Browser.FolderViewModel] = [] - var selectedFolder: DuckDuckGo_Privacy_Browser.BookmarkFolder? { - didSet { - selectedFolderExpectation?.fulfill() + func testWhenAddFolderMultipleTimesThenFolderListIsUpdatedAndSelectedFolderIsNil() { + // GIVEN + let expectation = self.expectation(description: #function) + let folder = BookmarkFolder(id: "1", title: "Folder") + bookmarkViewModelMock.selectedFolder = folder + let bookmarkStoreMock = BookmarkStoreMock() + bookmarkStoreMock.bookmarks = [folder] + let bookmarkManager = LocalBookmarkManager(bookmarkStore: bookmarkStoreMock, faviconManagement: FaviconManagerMock()) + bookmarkManager.loadBookmarks() + let folderModel = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: nil), bookmarkManager: bookmarkManager) + let sut = AddEditBookmarkDialogCoordinatorViewModel(bookmarkModel: bookmarkViewModelMock, folderModel: folderModel) + let c = folderModel.$folders + .dropFirst(2) // Not interested in the first two events. 1.subscribing to $folders and 2. subscribing to $list. + .sink { folders in + expectation.fulfill() } - } - var cancelActionTitle: String = "" - var isOtherActionDisabled: Bool = false - var defaultActionTitle: String = "" - var isDefaultActionDisabled: Bool = false - func cancel(dismiss: () -> Void) {} - func addOrSave(dismiss: () -> Void) {} + XCTAssertNil(folderModel.selectedFolder) - var selectedFolderExpectation: XCTestExpectation? -} + // Tap Add Folder + sut.addFolderAction() + XCTAssertEqual(sut.viewState, .folder) + XCTAssertTrue(folderModel.folderName.isEmpty) + XCTAssertEqual(folderModel.selectedFolder, folder) -final class AddEditBookmarkFolderDialogViewModelMock: BookmarkFolderDialogEditing { - let subject = PassthroughSubject() + // Create a new folder + folderModel.folderName = #function + folderModel.addOrSave {} + + // Add folder again + sut.addFolderAction() - var addFolderPublisher: AnyPublisher { - subject.eraseToAnyPublisher() + // THEN + withExtendedLifetime(c) {} + waitForExpectations(timeout: 1.0) + XCTAssertEqual(sut.viewState, .folder) + XCTAssertTrue(folderModel.folderName.isEmpty) } - var folderName: String = "" - var title: String = "" - var folders: [DuckDuckGo_Privacy_Browser.FolderViewModel] = [] - var selectedFolder: DuckDuckGo_Privacy_Browser.BookmarkFolder? - var cancelActionTitle: String = "" - var isOtherActionDisabled: Bool = false - var defaultActionTitle: String = "" - var isDefaultActionDisabled: Bool = false - - func cancel(dismiss: () -> Void) {} - func addOrSave(dismiss: () -> Void) {} } diff --git a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogViewModelTests.swift index b409df5b9c..615777c7d9 100644 --- a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogViewModelTests.swift +++ b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogViewModelTests.swift @@ -119,25 +119,61 @@ final class AddEditBookmarkDialogViewModelTests: XCTestCase { XCTAssertTrue(result.isEmpty) } - func testShouldSetNameAndURLToValueWhenInitModeIsAddTabInfoIsNotNilAndURLIsNotAlreadyBookmarked() { + func testWhenInitModeIsAddAndTabInfoIsNotNilAndURLIsNotAlreadyBookmarkedThenSetURLToValue() { // GIVEN let tab = Tab(content: .url(URL.duckDuckGo, source: .link), title: "Test") let sut = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: WebsiteInfo(tab)), bookmarkManager: bookmarkManager) // WHEN - let name = sut.bookmarkName let url = sut.bookmarkURLPath // THEN - XCTAssertEqual(name, "Test") XCTAssertEqual(url, URL.duckDuckGo.absoluteString) } + func testWhenInitAndModeIsAddAndTabInfoTitleIsNotNilAndURLIsNotAlreadyBookmarkedThenSetBookmarkNameToTitle() { + // GIVEN + let tab = Tab(content: .url(URL.duckDuckGo, source: .link), title: "Test") + let sut = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: WebsiteInfo(tab)), bookmarkManager: bookmarkManager) + + // WHEN + let name = sut.bookmarkName + + // THEN + XCTAssertEqual(name, "Test") + } + + func testWhenInitAndModeIsAddAndTabInfoTitleIsNilAndURLIsNotAlreadyBookmarkedThenSetBookmarkNameToURLDomain() { + // GIVEN + let url = URL.duckDuckGo + let tab = Tab(content: .url(url, source: .link), title: nil) + let sut = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: WebsiteInfo(tab)), bookmarkManager: bookmarkManager) + + // WHEN + let name = sut.bookmarkName + + // THEN + XCTAssertEqual(name, url.host) + } + + func testWhenInitAndModeIsAddAndTabInfoTitleIsNilAndURLDoesNotConformToRFC3986AndURLIsNotAlreadyBookmarkedThenSetBookmarkNameToURLAbsoluteString() throws { + // GIVEN + let url = try XCTUnwrap(URL(string: "duckduckgo.com")) + let tab = Tab(content: .url(url, source: .link), title: nil) + let sut = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: WebsiteInfo(tab)), bookmarkManager: bookmarkManager) + + // WHEN + let name = sut.bookmarkName + + // THEN + XCTAssertEqual(name, url.absoluteString) + } + func testShouldSetNameAndURLToEmptyWhenInitModeIsAddTabInfoIsNotNilAndURLIsAlreadyBookmarked() throws { // GIVEN let tab = Tab(content: .url(URL.duckDuckGo, source: .link), title: "Test") let websiteInfo = try XCTUnwrap(WebsiteInfo(tab)) - let bookmark = Bookmark(id: "1", url: websiteInfo.url.absoluteString, title: websiteInfo.title ?? "", isFavorite: false) + let bookmark = Bookmark(id: "1", url: websiteInfo.url.absoluteString, title: websiteInfo.title, isFavorite: false) bookmarkStoreMock.bookmarks = [bookmark] bookmarkManager.loadBookmarks() let sut = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: WebsiteInfo(tab)), bookmarkManager: bookmarkManager) diff --git a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkFolderDialogViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkFolderDialogViewModelTests.swift index a48f18d2ab..6d54257048 100644 --- a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkFolderDialogViewModelTests.swift +++ b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkFolderDialogViewModelTests.swift @@ -329,7 +329,7 @@ final class AddEditBookmarkFolderDialogViewModelTests: XCTestCase { // THEN XCTAssertFalse(bookmarkStoreMock.updateFolderCalled) XCTAssertTrue(bookmarkStoreMock.saveFolderCalled) - XCTAssertEqual(bookmarkStoreMock.capturedFolder?.title, sut.folderName) + XCTAssertEqual(bookmarkStoreMock.capturedFolder?.title, #function) XCTAssertEqual(bookmarkStoreMock.capturedParentFolder, folder) } @@ -346,7 +346,7 @@ final class AddEditBookmarkFolderDialogViewModelTests: XCTestCase { // THEN XCTAssertTrue(bookmarkStoreMock.updateFolderCalled) - XCTAssertEqual(bookmarkStoreMock.capturedFolder?.title, sut.folderName) + XCTAssertEqual(bookmarkStoreMock.capturedFolder?.title, "TEST") } func testShouldNotAskBookmarkStoreToUpdateFolderWhenNameIsNotChanged() { diff --git a/UnitTests/Bookmarks/ViewModels/BookmarkAllTabsDialogCoordinatorViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/BookmarkAllTabsDialogCoordinatorViewModelTests.swift new file mode 100644 index 0000000000..d766d27f0d --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/BookmarkAllTabsDialogCoordinatorViewModelTests.swift @@ -0,0 +1,171 @@ +// +// BookmarkAllTabsDialogCoordinatorViewModelTests.swift +// +// Copyright © 2024 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 XCTest +import Combine +@testable import DuckDuckGo_Privacy_Browser + +@MainActor +final class BookmarkAllTabsDialogCoordinatorViewModelTests: XCTestCase { + private var sut: BookmarkAllTabsDialogCoordinatorViewModel! + private var bookmarkAllTabsViewModelMock: BookmarkAllTabsDialogViewModelMock! + private var bookmarkFolderViewModelMock: AddEditBookmarkFolderDialogViewModelMock! + private var cancellables: Set! + + override func setUpWithError() throws { + try super.setUpWithError() + + cancellables = [] + bookmarkAllTabsViewModelMock = .init() + bookmarkFolderViewModelMock = .init() + sut = .init(bookmarkModel: bookmarkAllTabsViewModelMock, folderModel: bookmarkFolderViewModelMock) + } + + override func tearDownWithError() throws { + cancellables = nil + bookmarkAllTabsViewModelMock = nil + bookmarkFolderViewModelMock = nil + sut = nil + try super.tearDownWithError() + } + + func testWhenInitThenViewStateIsBookmarkAllTabs() { + XCTAssertEqual(sut.viewState, .bookmarkAllTabs) + } + + func testWhenDismissActionIsCalledThenViewStateIsBookmarkAllTabs() { + // GIVEN + sut.addFolderAction() + XCTAssertEqual(sut.viewState, .addFolder) + + // WHEN + sut.dismissAction() + + // THEN + XCTAssertEqual(sut.viewState, .bookmarkAllTabs) + + } + + func testWhenAddFolderActionIsCalledThenSetSelectedFolderOnFolderViewModelIsCalledAndReturnAddFolderViewState() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: "Folder") + bookmarkAllTabsViewModelMock.selectedFolder = folder + XCTAssertNil(bookmarkFolderViewModelMock.selectedFolder) + XCTAssertEqual(sut.viewState, .bookmarkAllTabs) + + // WHEN + sut.addFolderAction() + + // THEN + XCTAssertEqual(bookmarkFolderViewModelMock.selectedFolder, folder) + XCTAssertEqual(sut.viewState, .addFolder) + } + + func testWhenBookmarkModelChangesThenReceiveEvent() { + // GIVEN + let expectation = self.expectation(description: #function) + var didCallChangeValue = false + sut.objectWillChange.sink { _ in + didCallChangeValue = true + expectation.fulfill() + } + .store(in: &cancellables) + + // WHEN + sut.bookmarkModel.objectWillChange.send() + + // THEN + waitForExpectations(timeout: 1.0) + XCTAssertTrue(didCallChangeValue) + } + + func testWhenBookmarkFolderModelChangesThenReceiveEvent() { + // GIVEN + let expectation = self.expectation(description: #function) + var didCallChangeValue = false + sut.objectWillChange.sink { _ in + didCallChangeValue = true + expectation.fulfill() + } + .store(in: &cancellables) + + // WHEN + sut.folderModel.objectWillChange.send() + + // THEN + waitForExpectations(timeout: 1.0) + XCTAssertTrue(didCallChangeValue) + } + + func testWhenAddFolderPublisherSendsEventThenSelectedFolderOnBookmarkAllTabsViewModelIsSet() { + // GIVEN + let expectation = self.expectation(description: #function) + bookmarkAllTabsViewModelMock.selectedFolderExpectation = expectation + let folder = BookmarkFolder(id: "ABCDE", title: #function) + XCTAssertNil(bookmarkAllTabsViewModelMock.selectedFolder) + + // WHEN + sut.folderModel.subject.send(folder) + + // THEN + waitForExpectations(timeout: 1.0) + XCTAssertEqual(bookmarkAllTabsViewModelMock.selectedFolder, folder) + } + + // MARK: - Integration Test + + func testWhenAddFolderMultipleTimesThenFolderListIsUpdatedAndSelectedFolderIsNil() { + // GIVEN + let expectation = self.expectation(description: #function) + let folder = BookmarkFolder(id: "1", title: "Folder") + bookmarkAllTabsViewModelMock.selectedFolder = folder + let bookmarkStoreMock = BookmarkStoreMock() + bookmarkStoreMock.bookmarks = [folder] + let bookmarkManager = LocalBookmarkManager(bookmarkStore: bookmarkStoreMock, faviconManagement: FaviconManagerMock()) + bookmarkManager.loadBookmarks() + let folderModel = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: nil), bookmarkManager: bookmarkManager) + let sut = BookmarkAllTabsDialogCoordinatorViewModel(bookmarkModel: bookmarkAllTabsViewModelMock, folderModel: folderModel) + let c = folderModel.$folders + .dropFirst(2) // Not interested in the first two events. 1.subscribing to $folders and 2. subscribing to $list. + .sink { folders in + expectation.fulfill() + } + + XCTAssertNil(folderModel.selectedFolder) + + // Tap Add Folder + sut.addFolderAction() + XCTAssertEqual(sut.viewState, .addFolder) + XCTAssertTrue(folderModel.folderName.isEmpty) + XCTAssertEqual(folderModel.selectedFolder, folder) + + // Create a new folder + folderModel.folderName = #function + folderModel.addOrSave {} + + // Add folder again + sut.addFolderAction() + + // THEN + withExtendedLifetime(c) {} + waitForExpectations(timeout: 1.0) + XCTAssertEqual(sut.viewState, .addFolder) + XCTAssertTrue(folderModel.folderName.isEmpty) + } + +} diff --git a/UnitTests/Bookmarks/ViewModels/BookmarkAllTabsDialogViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/BookmarkAllTabsDialogViewModelTests.swift new file mode 100644 index 0000000000..dd05e42238 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/BookmarkAllTabsDialogViewModelTests.swift @@ -0,0 +1,382 @@ +// +// BookmarkAllTabsDialogViewModelTests.swift +// +// Copyright © 2024 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +@MainActor +final class BookmarkAllTabsDialogViewModelTests: XCTestCase { + private var bookmarkManager: LocalBookmarkManager! + private var bookmarkStoreMock: BookmarkStoreMock! + private var foldersStoreMock: BookmarkFolderStoreMock! + + override func setUpWithError() throws { + try super.setUpWithError() + bookmarkStoreMock = BookmarkStoreMock() + bookmarkStoreMock.bookmarks = [BookmarkFolder.mock] + bookmarkManager = .init(bookmarkStore: bookmarkStoreMock, faviconManagement: FaviconManagerMock()) + bookmarkManager.loadBookmarks() + foldersStoreMock = .init() + } + + override func tearDownWithError() throws { + bookmarkStoreMock = nil + bookmarkManager = nil + foldersStoreMock = nil + try super.tearDownWithError() + } + + // MARK: - Copy + + func testWhenTitleIsCalledThenItReflectsThenNumberOfWebsites() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, occurrences: 10) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.title + + // THEN + XCTAssertEqual(title, String(format: UserText.Bookmarks.Dialog.Title.bookmarkOpenTabs, websitesInfo.count)) + } + + func testWhenCancelActionTitleIsCalledThenItReturnsTheRightTitle() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.cancelActionTitle + + // THEN + XCTAssertEqual(title, UserText.cancel) + } + + func testWhenEducationalMessageIsCalledThenItReturnsTheRightMessage() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.educationalMessage + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Message.bookmarkOpenTabsEducational) + } + + func testWhenDefaultActionTitleIsCalledThenItReturnsTheRightTitle() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.defaultActionTitle + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Action.addAllBookmarks) + } + + func testWhenFolderNameFieldTitleIsCalledThenItReturnsTheRightTitle() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.folderNameFieldTitle + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Field.folderName) + } + + func testWhenLocationFieldTitleIsCalledThenItReturnsTheRightTitle() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.locationFieldTitle + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Field.location) + } + + // MARK: - State + + func testWhenInitThenFolderNameIsSetToCurrentDateAndNumberOfWebsites() throws { + // GIVEN + let date = Date(timeIntervalSince1970: 1712902304) // 12th of April 2024 + let gmtTimeZone = try XCTUnwrap(TimeZone(identifier: "GMT")) + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, occurrences: 5) + let sut = BookmarkAllTabsDialogViewModel( + websites: websitesInfo, + foldersStore: foldersStoreMock, + bookmarkManager: bookmarkManager, + dateFormatterConfigurationProvider: { + BookmarkAllTabsDialogViewModel.DateFormatterConfiguration(date: date, timeZone: gmtTimeZone) + } + ) + + // WHEN + let result = sut.folderName + + // THEN + XCTAssertEqual(result, String(format: UserText.Bookmarks.Dialog.Value.folderName, "2024-04-12", websitesInfo.count)) + } + + func testWhenInitAndTimeZoneIsPDTThenFolderNameIsSetToCurrentDateAndNumberOfWebsites() throws { + // GIVEN + let date = Date(timeIntervalSince1970: 1712902304) // 12th of April 2024 (GMT) + let pdtTimeZone = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles")) + let expectedDate = "2024-04-11" // Expected date in PDT TimeZone + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo, occurrences: 5) + let sut = BookmarkAllTabsDialogViewModel( + websites: websitesInfo, + foldersStore: foldersStoreMock, + bookmarkManager: bookmarkManager, + dateFormatterConfigurationProvider: { + BookmarkAllTabsDialogViewModel.DateFormatterConfiguration(date: date, timeZone: pdtTimeZone) + } + ) + + // WHEN + let result = sut.folderName + + // THEN + XCTAssertEqual(result, String(format: UserText.Bookmarks.Dialog.Value.folderName, expectedDate, websitesInfo.count)) + } + + func testWhenInitThenFoldersAreSetFromBookmarkList() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.folders + + // THEN + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.entity, folder) + } + + func testWhenInitAndFoldersStoreLastUsedFolderIsNilThenDoNotAskBookmarkStoreForBookmarkFolder() { + // GIVEN + foldersStoreMock.lastBookmarkAllTabsFolderIdUsed = nil + XCTAssertFalse(bookmarkStoreMock.bookmarkFolderWithIdCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolderId) + + // WHEN + _ = BookmarkAllTabsDialogViewModel(websites: WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo), foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // THEN + XCTAssertFalse(bookmarkStoreMock.bookmarkFolderWithIdCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolderId) + } + + func testWhenInitAndFoldersStoreLastUsedFolderIsNotNilThenAskBookmarkStoreForBookmarkFolder() { + foldersStoreMock.lastBookmarkAllTabsFolderIdUsed = "1ABCDE" + XCTAssertFalse(bookmarkStoreMock.bookmarkFolderWithIdCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolderId) + + // WHEN + _ = BookmarkAllTabsDialogViewModel(websites: WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo), foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // THEN + XCTAssertTrue(bookmarkStoreMock.bookmarkFolderWithIdCalled) + XCTAssertEqual(bookmarkStoreMock.capturedFolderId, "1ABCDE") + } + + func testWhenFoldersStoreLastUsedFolderIsNotNilAndBookmarkStoreDoesNotContainFolderThenSelectedFolderIsNil() throws { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + foldersStoreMock.lastBookmarkAllTabsFolderIdUsed = "1" + bookmarkStoreMock.bookmarkFolder = nil + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertNil(result) + } + + func testWhenFoldersStoreLastUsedFolderIsNotNilThenSelectedFolderIsNotNil() throws { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + foldersStoreMock.lastBookmarkAllTabsFolderIdUsed = "1" + bookmarkStoreMock.bookmarkFolder = folder + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertEqual(result, folder) + } + + func testWhenFolderIsAddedThenFoldersListIsRefreshed() { + // GIVEN + let expectation = self.expectation(description: #function) + expectation.expectedFulfillmentCount = 2 + let folder = BookmarkFolder(id: "1", title: #function) + let folder2 = BookmarkFolder(id: "2", title: "Test") + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + XCTAssertEqual(sut.folders.count, 1) + XCTAssertEqual(sut.folders.first?.entity, folder) + + // Simulate Bookmark store changing data set + bookmarkStoreMock.bookmarks = [folder, folder2] + var expectedFolder: [BookmarkFolder] = [] + let c = sut.$folders + .dropFirst() + .sink { folders in + expectedFolder = folders.map(\.entity) + expectation.fulfill() + } + + // WHEN + bookmarkManager.loadBookmarks() + + // THEN + withExtendedLifetime(c) {} + waitForExpectations(timeout: 1.0) + XCTAssertEqual(expectedFolder.count, 2) + XCTAssertEqual(expectedFolder.first, folder) + XCTAssertEqual(expectedFolder.last, folder2) + } + + // MARK: - Actions + + func testWhenIsOtherActionDisabledCalledThenReturnFalse() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.isOtherActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testWhenFolderNameIsEmptyDefaultActionIsDisabled() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + sut.folderName = "" + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertTrue(result) + } + + func testWhenFolderNameIsNotEmptyDefaultActionIsEnabled() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + sut.folderName = "TEST" + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testWhenCancelIsCalledThenDismissIsCalled() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + var didCallDismiss = false + + // WHEN + sut.cancel { + didCallDismiss = true + } + + // THEN + XCTAssertTrue(didCallDismiss) + } + + func testWhenAddOrSaveIsCalledAndSelectedFolderIsNilThenBookmarkStoreIsAskedToBookmarkWebsitesInfoInRootFolder() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + sut.selectedFolder = nil + XCTAssertFalse(bookmarkStoreMock.saveBookmarksInNewFolderNamedCalled) + XCTAssertNil(bookmarkStoreMock.capturedWebsitesInfo) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave(dismiss: {}) + + // THEN + XCTAssertTrue(bookmarkStoreMock.saveBookmarksInNewFolderNamedCalled) + XCTAssertEqual(bookmarkStoreMock.capturedWebsitesInfo, websitesInfo) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .root) + + } + + func testWhenAddOrSaveIsCalledAndSelectedFolderIsNotNilThenBookmarkStoreIsAskedToBookmarkWebsitesInfoNotInRootFolder() { + // GIVEN + let folder = BookmarkFolder(id: "ABCDE", title: "Saved Tabs") + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + sut.selectedFolder = folder + XCTAssertFalse(bookmarkStoreMock.saveBookmarksInNewFolderNamedCalled) + XCTAssertNil(bookmarkStoreMock.capturedWebsitesInfo) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave(dismiss: {}) + + // THEN + XCTAssertTrue(bookmarkStoreMock.saveBookmarksInNewFolderNamedCalled) + XCTAssertEqual(bookmarkStoreMock.capturedWebsitesInfo, websitesInfo) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .parent(uuid: "ABCDE")) + } + + func testWhenAddOrSaveIsCalledThenDismissIsCalled() { + // GIVEN + let websitesInfo = WebsiteInfo.makeWebsitesInfo(url: .duckDuckGo) + let sut = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: foldersStoreMock, bookmarkManager: bookmarkManager) + var didCallDismiss = false + + // WHEN + sut.addOrSave { + didCallDismiss = true + } + + // THEN + XCTAssertTrue(didCallDismiss) + } + +} diff --git a/UnitTests/Bookmarks/ViewModels/Mocks/AddEditBookmarkDialogViewModelMock.swift b/UnitTests/Bookmarks/ViewModels/Mocks/AddEditBookmarkDialogViewModelMock.swift new file mode 100644 index 0000000000..6cd4175ac6 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/Mocks/AddEditBookmarkDialogViewModelMock.swift @@ -0,0 +1,43 @@ +// +// AddEditBookmarkDialogViewModelMock.swift +// +// Copyright © 2024 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class AddEditBookmarkDialogViewModelMock: BookmarkDialogEditing { + var bookmarkName: String = "" + var bookmarkURLPath: String = "" + var isBookmarkFavorite: Bool = false + var isURLFieldHidden: Bool = false + var title: String = "" + var folders: [DuckDuckGo_Privacy_Browser.FolderViewModel] = [] + var selectedFolder: DuckDuckGo_Privacy_Browser.BookmarkFolder? { + didSet { + selectedFolderExpectation?.fulfill() + } + } + var cancelActionTitle: String = "" + var isOtherActionDisabled: Bool = false + var defaultActionTitle: String = "" + var isDefaultActionDisabled: Bool = false + + func cancel(dismiss: () -> Void) {} + func addOrSave(dismiss: () -> Void) {} + + var selectedFolderExpectation: XCTestExpectation? +} diff --git a/UnitTests/Bookmarks/ViewModels/Mocks/AddEditBookmarkFolderDialogViewModelMock.swift b/UnitTests/Bookmarks/ViewModels/Mocks/AddEditBookmarkFolderDialogViewModelMock.swift new file mode 100644 index 0000000000..99317bf853 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/Mocks/AddEditBookmarkFolderDialogViewModelMock.swift @@ -0,0 +1,40 @@ +// +// AddEditBookmarkFolderDialogViewModelMock.swift +// +// Copyright © 2024 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 Combine +@testable import DuckDuckGo_Privacy_Browser + +final class AddEditBookmarkFolderDialogViewModelMock: BookmarkFolderDialogEditing { + let subject = PassthroughSubject() + + var addFolderPublisher: AnyPublisher { + subject.eraseToAnyPublisher() + } + var folderName: String = "" + var title: String = "" + var folders: [DuckDuckGo_Privacy_Browser.FolderViewModel] = [] + var selectedFolder: DuckDuckGo_Privacy_Browser.BookmarkFolder? + var cancelActionTitle: String = "" + var isOtherActionDisabled: Bool = false + var defaultActionTitle: String = "" + var isDefaultActionDisabled: Bool = false + + func cancel(dismiss: () -> Void) {} + func addOrSave(dismiss: () -> Void) {} +} diff --git a/UnitTests/Bookmarks/ViewModels/Mocks/BookmarkAllTabsDialogViewModelMock.swift b/UnitTests/Bookmarks/ViewModels/Mocks/BookmarkAllTabsDialogViewModelMock.swift new file mode 100644 index 0000000000..3d9e9ba3e7 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/Mocks/BookmarkAllTabsDialogViewModelMock.swift @@ -0,0 +1,43 @@ +// +// BookmarkAllTabsDialogViewModelMock.swift +// +// Copyright © 2024 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class BookmarkAllTabsDialogViewModelMock: BookmarkAllTabsDialogEditing { + var folderName: String = "" + var educationalMessage: String = "" + var folderNameFieldTitle: String = "" + var locationFieldTitle: String = "" + var title: String = "" + var folders: [DuckDuckGo_Privacy_Browser.FolderViewModel] = [] + var selectedFolder: DuckDuckGo_Privacy_Browser.BookmarkFolder? { + didSet { + selectedFolderExpectation?.fulfill() + } + } + var cancelActionTitle: String = "" + var isOtherActionDisabled: Bool = false + var defaultActionTitle: String = "" + var isDefaultActionDisabled: Bool = true + + func cancel(dismiss: () -> Void) {} + func addOrSave(dismiss: () -> Void) {} + + var selectedFolderExpectation: XCTestExpectation? +} diff --git a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift index 28f65fdf59..d1fafb18be 100644 --- a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift +++ b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift @@ -41,6 +41,10 @@ class MockBookmarkManager: BookmarkManager { return nil } + func getBookmarkFolder(withId id: String) -> DuckDuckGo_Privacy_Browser.BookmarkFolder? { + return nil + } + func makeBookmark(for url: URL, title: String, isFavorite: Bool) -> DuckDuckGo_Privacy_Browser.Bookmark? { return nil } @@ -49,6 +53,8 @@ class MockBookmarkManager: BookmarkManager { return nil } + func makeBookmarks(for websitesInfo: [DuckDuckGo_Privacy_Browser.WebsiteInfo], inNewFolderNamed folderName: String, withinParentFolder parent: DuckDuckGo_Privacy_Browser.ParentFolderType) {} + func makeFolder(for title: String, parent: DuckDuckGo_Privacy_Browser.BookmarkFolder?, completion: (DuckDuckGo_Privacy_Browser.BookmarkFolder) -> Void) {} func remove(bookmark: DuckDuckGo_Privacy_Browser.Bookmark) {} diff --git a/UnitTests/Menus/MainMenuTests.swift b/UnitTests/Menus/MainMenuTests.swift index 44ec9cdb11..b3b5f4275e 100644 --- a/UnitTests/Menus/MainMenuTests.swift +++ b/UnitTests/Menus/MainMenuTests.swift @@ -18,6 +18,7 @@ import XCTest import Combine +import BrowserServicesKit @testable import DuckDuckGo_Privacy_Browser class MainMenuTests: XCTestCase { @@ -87,4 +88,26 @@ class MainMenuTests: XCTestCase { XCTAssertEqual(manager.reopenLastClosedMenuItem?.keyEquivalent, ReopenMenuItemKeyEquivalentManager.Const.keyEquivalent) XCTAssertEqual(manager.reopenLastClosedMenuItem?.keyEquivalentModifierMask, ReopenMenuItemKeyEquivalentManager.Const.modifierMask) } + + // MARK: - Bookmarks + + @MainActor + func testWhenBookmarksMenuIsInitialized_ThenSecondItemIsBookmarkAllTabs() throws { + // GIVEN + let sut = MainMenu(featureFlagger: DummyFeatureFlagger(), bookmarkManager: MockBookmarkManager(), faviconManager: FaviconManagerMock(), copyHandler: CopyHandler()) + let bookmarksMenu = try XCTUnwrap(sut.item(withTitle: UserText.bookmarks)) + + // WHEN + let result = try XCTUnwrap(bookmarksMenu.submenu?.item(withTitle: UserText.bookmarkAllTabs)) + + // THEN + XCTAssertEqual(result.keyEquivalent, "d") + XCTAssertEqual(result.keyEquivalentModifierMask, [.command, .shift]) + } +} + +private class DummyFeatureFlagger: FeatureFlagger { + func isFeatureOn(forProvider: F) -> Bool { + false + } } diff --git a/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift b/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift index e37582c4fa..05e9c206e7 100644 --- a/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift +++ b/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift @@ -23,6 +23,7 @@ class CapturingOptionsButtonMenuDelegate: OptionsButtonMenuDelegate { var optionsButtonMenuRequestedPreferencesCalled = false var optionsButtonMenuRequestedAppearancePreferencesCalled = false + var optionsButtonMenuRequestedBookmarkAllOpenTabsCalled = false func optionsButtonMenuRequestedDataBrokerProtection(_ menu: NSMenu) { @@ -36,6 +37,10 @@ class CapturingOptionsButtonMenuDelegate: OptionsButtonMenuDelegate { } + func optionsButtonMenuRequestedBookmarkAllOpenTabs(_ sender: NSMenuItem) { + optionsButtonMenuRequestedBookmarkAllOpenTabsCalled = true + } + func optionsButtonMenuRequestedBookmarkPopover(_ menu: NSMenu) { } diff --git a/UnitTests/Menus/MoreOptionsMenu+BookmarksTests.swift b/UnitTests/Menus/MoreOptionsMenu+BookmarksTests.swift new file mode 100644 index 0000000000..3888c658a1 --- /dev/null +++ b/UnitTests/Menus/MoreOptionsMenu+BookmarksTests.swift @@ -0,0 +1,61 @@ +// +// MoreOptionsMenu+BookmarksTests.swift +// +// Copyright © 2024 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +@MainActor +final class MoreOptionsMenu_BookmarksTests: XCTestCase { + + func testWhenBookmarkSubmenuIsInitThenBookmarkAllTabsKeyIsCmdShiftD() throws { + // GIVEN + let sut = BookmarksSubMenu(targetting: self, tabCollectionViewModel: .init()) + + // WHEN + let result = try XCTUnwrap(sut.item(withTitle: UserText.bookmarkAllTabs)) + + // THEN + XCTAssertEqual(result.keyEquivalent, "d") + XCTAssertEqual(result.keyEquivalentModifierMask, [.command, .shift]) + } + + func testWhenTabCollectionCanBookmarkAllTabsThenBookmarkAllTabsMenuItemIsEnabled() throws { + // GIVEN + let tab1 = Tab(content: .url(.duckDuckGo, credential: nil, source: .ui)) + let tab2 = Tab(content: .url(.duckDuckGoEmail, credential: nil, source: .ui)) + let sut = BookmarksSubMenu(targetting: self, tabCollectionViewModel: .init(tabCollection: .init(tabs: [tab1, tab2]))) + + // WHEN + let result = try XCTUnwrap(sut.item(withTitle: UserText.bookmarkAllTabs)) + + // THEN + XCTAssertTrue(result.isEnabled) + } + + func testWhenTabCollectionCannotBookmarkAllTabsThenBookmarkAllTabsMenuItemIsDisabled() throws { + // GIVEN + let sut = BookmarksSubMenu(targetting: self, tabCollectionViewModel: .init(tabCollection: .init(tabs: []))) + + // WHEN + let result = try XCTUnwrap(sut.item(withTitle: UserText.bookmarkAllTabs)) + + // THEN + XCTAssertFalse(result.isEnabled) + } + +} diff --git a/UnitTests/Menus/MoreOptionsMenuTests.swift b/UnitTests/Menus/MoreOptionsMenuTests.swift index 4b9994cb5e..209701e87a 100644 --- a/UnitTests/Menus/MoreOptionsMenuTests.swift +++ b/UnitTests/Menus/MoreOptionsMenuTests.swift @@ -155,6 +155,23 @@ final class MoreOptionsMenuTests: XCTestCase { XCTAssertTrue(capturingActionDelegate.optionsButtonMenuRequestedPreferencesCalled) } + // MARK: - Bookmarks + + @MainActor + func testWhenClickingOnBookmarkAllTabsMenuItemThenTheActionDelegateIsAlerted() throws { + // GIVEN + let bookmarksMenu = try XCTUnwrap(moreOptionMenu.item(at: 8)?.submenu) + let bookmarkAllTabsIndex = try XCTUnwrap(bookmarksMenu.indexOfItem(withTitle: UserText.bookmarkAllTabs)) + let bookmarkAllTabsMenuItem = try XCTUnwrap(bookmarksMenu.items[bookmarkAllTabsIndex]) + bookmarkAllTabsMenuItem.isEnabled = true + + // WHEN + bookmarksMenu.performActionForItem(at: bookmarkAllTabsIndex) + + // THEN + XCTAssertTrue(capturingActionDelegate.optionsButtonMenuRequestedBookmarkAllOpenTabsCalled) + } + } final class NetworkProtectionVisibilityMock: NetworkProtectionFeatureVisibility { diff --git a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift index e63dad9b0a..9157000830 100644 --- a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift +++ b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift @@ -23,9 +23,12 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { var mockedCurrentTab: Tab? + var canBookmarkAllOpenTabs = false var hasItemsToTheRight = false var audioState: WKWebView.AudioState? + private(set) var tabBarViewItemBookmarkAllOpenTabsActionCalled = false + func tabBarViewItem(_ tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem, isMouseOver: Bool) { } @@ -70,6 +73,14 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { } + func tabBarViewAllItemsCanBeBookmarked(_ tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem) -> Bool { + canBookmarkAllOpenTabs + } + + func tabBarViewItemBookmarkAllOpenTabsAction(_ tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem) { + tabBarViewItemBookmarkAllOpenTabsActionCalled = true + } + func tabBarViewItemMoveToNewWindowAction(_ tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem) { } diff --git a/UnitTests/TabBar/View/TabBarViewItemTests.swift b/UnitTests/TabBar/View/TabBarViewItemTests.swift index 208d73a29e..1c2c574b70 100644 --- a/UnitTests/TabBar/View/TabBarViewItemTests.swift +++ b/UnitTests/TabBar/View/TabBarViewItemTests.swift @@ -45,31 +45,33 @@ final class TabBarViewItemTests: XCTestCase { XCTAssertEqual(menu.item(at: 0)?.title, UserText.duplicateTab) XCTAssertEqual(menu.item(at: 1)?.title, UserText.pinTab) XCTAssertTrue(menu.item(at: 2)?.isSeparatorItem ?? false) - XCTAssertEqual(menu.item(at: 3)?.title, UserText.bookmarkThisPage) - XCTAssertEqual(menu.item(at: 4)?.title, UserText.fireproofSite) + XCTAssertEqual(menu.item(at: 3)?.title, UserText.fireproofSite) + XCTAssertEqual(menu.item(at: 4)?.title, UserText.bookmarkThisPage) XCTAssertTrue(menu.item(at: 5)?.isSeparatorItem ?? false) - XCTAssertEqual(menu.item(at: 6)?.title, UserText.closeTab) - XCTAssertEqual(menu.item(at: 7)?.title, UserText.closeOtherTabs) - XCTAssertEqual(menu.item(at: 8)?.title, UserText.closeTabsToTheRight) - XCTAssertEqual(menu.item(at: 9)?.title, UserText.moveTabToNewWindow) + XCTAssertEqual(menu.item(at: 6)?.title, UserText.bookmarkAllTabs) + XCTAssertTrue(menu.item(at: 7)?.isSeparatorItem ?? false) + XCTAssertEqual(menu.item(at: 8)?.title, UserText.closeTab) + XCTAssertEqual(menu.item(at: 9)?.title, UserText.closeOtherTabs) + XCTAssertEqual(menu.item(at: 10)?.title, UserText.closeTabsToTheRight) + XCTAssertEqual(menu.item(at: 11)?.title, UserText.moveTabToNewWindow) } func testThatMuteIsShownWhenCurrentAudioStateIsUnmuted() { delegate.audioState = .unmuted tabBarViewItem.menuNeedsUpdate(menu) - XCTAssertTrue(menu.item(at: 5)?.isSeparatorItem ?? false) - XCTAssertEqual(menu.item(at: 6)?.title, UserText.muteTab) - XCTAssertTrue(menu.item(at: 7)?.isSeparatorItem ?? false) + XCTAssertFalse(menu.item(at: 1)?.isSeparatorItem ?? true) + XCTAssertEqual(menu.item(at: 2)?.title, UserText.muteTab) + XCTAssertTrue(menu.item(at: 3)?.isSeparatorItem ?? false) } func testThatUnmuteIsShownWhenCurrentAudioStateIsMuted() { delegate.audioState = .muted tabBarViewItem.menuNeedsUpdate(menu) - XCTAssertTrue(menu.item(at: 5)?.isSeparatorItem ?? false) - XCTAssertEqual(menu.item(at: 6)?.title, UserText.unmuteTab) - XCTAssertTrue(menu.item(at: 7)?.isSeparatorItem ?? false) + XCTAssertFalse(menu.item(at: 1)?.isSeparatorItem ?? true) + XCTAssertEqual(menu.item(at: 2)?.title, UserText.unmuteTab) + XCTAssertTrue(menu.item(at: 3)?.isSeparatorItem ?? false) } func testWhenOneTabCloseThenOtherTabsItemIsDisabled() { @@ -193,4 +195,42 @@ final class TabBarViewItemTests: XCTestCase { XCTAssertFalse(bookmarkItem?.isEnabled ?? true) } + func testWhenCanBookmarkAllOpenTabsThenBookmarkAllOpenTabsItemIsEnabled() throws { + // GIVEN + delegate.canBookmarkAllOpenTabs = true + tabBarViewItem.menuNeedsUpdate(menu) + + // WHEN + let item = try XCTUnwrap(menu.item(withTitle: UserText.bookmarkAllTabs)) + + // THEN + XCTAssertTrue(item.isEnabled) + } + + func testWhenCannotBookmarkAllOpenTabsThenBookmarkAllOpenTabsItemIsDisabled() throws { + // GIVEN + delegate.canBookmarkAllOpenTabs = false + tabBarViewItem.menuNeedsUpdate(menu) + + // WHEN + let item = try XCTUnwrap(menu.item(withTitle: UserText.bookmarkAllTabs)) + + // THEN + XCTAssertFalse(item.isEnabled) + } + + func testWhenClickingOnBookmarkAllTabsThenTheActionDelegateIsNotified() throws { + // GIVEN + delegate.canBookmarkAllOpenTabs = true + tabBarViewItem.menuNeedsUpdate(menu) + let index = try XCTUnwrap(menu.indexOfItem(withTitle: UserText.bookmarkAllTabs)) + XCTAssertFalse(delegate.tabBarViewItemBookmarkAllOpenTabsActionCalled) + + // WHEN + menu.performActionForItem(at: index) + + // THEN + XCTAssertTrue(delegate.tabBarViewItemBookmarkAllOpenTabsActionCalled) + } + } diff --git a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift index efc39feac0..3415fd4439 100644 --- a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift +++ b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift @@ -425,6 +425,79 @@ final class TabCollectionViewModelTests: XCTestCase { XCTAssertEqual(events.count, 1) XCTAssertIdentical(events[0], tabCollectionViewModel.selectedTabViewModel) } + + // MARK: - Bookmark All Open Tabs + + func testWhenOneEmptyTabOpenThenCanBookmarkAllOpenTabsIsFalse() throws { + // GIVEN + let sut = TabCollectionViewModel.aTabCollectionViewModel() + let firstTabViewModel = try XCTUnwrap(sut.tabViewModel(at: 0)) + XCTAssertEqual(sut.tabViewModels.count, 1) + XCTAssertEqual(firstTabViewModel.tabContent, .newtab) + + // WHEN + let result = sut.canBookmarkAllOpenTabs() + + // THEN + XCTAssertFalse(result) + } + + func testWhenOneURLTabOpenThenCanBookmarkAllOpenTabsIsFalse() throws { + // GIVEN + let sut = TabCollectionViewModel.aTabCollectionViewModel() + sut.replaceTab(at: .unpinned(0), with: .init(content: .url(.duckDuckGo, credential: nil, source: .ui))) + let firstTabViewModel = try XCTUnwrap(sut.tabViewModel(at: 0)) + XCTAssertEqual(sut.tabViewModels.count, 1) + XCTAssertEqual(firstTabViewModel.tabContent, .url(.duckDuckGo, credential: nil, source: .ui)) + + // WHEN + let result = sut.canBookmarkAllOpenTabs() + + // THEN + XCTAssertFalse(result) + } + + func testWhenOneURLTabAndOnePinnedTabOpenThenCanBookmarkAllOpenTabsIsFalse() { + // GIVEN + let sut = TabCollectionViewModel.aTabCollectionViewModel() + sut.replaceTab(at: .unpinned(0), with: .init(content: .url(.duckDuckGo, credential: nil, source: .ui))) + sut.append(tab: .init(content: .url(.duckDuckGoEmail, credential: nil, source: .ui))) + sut.pinTab(at: 0) + XCTAssertEqual(sut.pinnedTabs.count, 1) + XCTAssertEqual(sut.tabViewModels.count, 1) + + // WHEN + let result = sut.canBookmarkAllOpenTabs() + + // THEN + XCTAssertFalse(result) + } + + func testWhenAtLeastTwoURLTabsOpenThenCanBookmarkAllOpenTabsIsTrue() { + // GIVEN + let sut = TabCollectionViewModel.aTabCollectionViewModel() + let pinnedTab = Tab(content: .url(.aboutDuckDuckGo, credential: nil, source: .ui)) + sut.append(tabs: [ + pinnedTab, + .init(content: .url(.duckDuckGo, credential: nil, source: .ui)), + .init(content: .newtab), + .init(content: .bookmarks), + .init(content: .anySettingsPane), + .init(content: .url(.duckDuckGoEmail, credential: nil, source: .ui)), + ]) + sut.pinTab(at: 1) + XCTAssertEqual(sut.pinnedTabs.count, 1) + XCTAssertEqual(sut.tabViewModels.count, 6) + XCTAssertEqual(sut.pinnedTabs.first, pinnedTab) + XCTAssertNil(sut.tabViewModels[pinnedTab]) + + // WHEN + let result = sut.canBookmarkAllOpenTabs() + + // THEN + XCTAssertTrue(result) + } + } fileprivate extension TabCollectionViewModel {