diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 47c3300..66e97e2 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -7,23 +7,34 @@ objects = { /* Begin PBXBuildFile section */ + 0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0167DB29293FA900B65783 /* RealDebridModels.swift */; }; 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; }; 0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */; }; 0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; }; + 0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */; }; 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; }; 0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */; }; 0C32FB532890D19D002BD219 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB522890D19D002BD219 /* AboutView.swift */; }; - 0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB542890D1BF002BD219 /* UIApplication.swift */; }; 0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; }; + 0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */; }; + 0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */; }; + 0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */; }; + 0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; }; + 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; }; + 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; }; + 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; }; 0C4CFC462897030D00AD9FAD /* Regex in Frameworks */ = {isa = PBXBuildFile; productRef = 0C4CFC452897030D00AD9FAD /* Regex */; }; 0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */; }; 0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */; }; + 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; }; + 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; }; 0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */; }; - 0C60B1EF28A1A00000E3FD7E /* SearchProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */; }; 0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; }; 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; }; 0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; }; 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; }; + 0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; }; + 0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; }; 0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; }; 0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7376EF28A97D1400D60918 /* SwiftUIX */; }; 0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; }; @@ -31,13 +42,15 @@ 0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */; }; 0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C78041C28BFB3EA001E8CA3 /* String.swift */; }; 0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */; }; - 0C794B69289DACC800DD1CC8 /* InstalledSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */; }; - 0C794B6B289DACF100DD1CC8 /* SourceCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */; }; + 0C794B69289DACC800DD1CC8 /* InstalledSourceButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */; }; + 0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */; }; 0C794B6D289EFA2E00DD1CC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */; }; 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; }; 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */; }; - 0C7D11FC28AA01E900ED92DB /* DynamicAccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FB28AA01E900ED92DB /* DynamicAccentColor.swift */; }; + 0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C128528DAA3CD00381CD1 /* URL.swift */; }; 0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; }; + 0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; }; + 0C7ED14328D65518009E29AD /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14228D65518009E29AD /* FileManager.swift */; }; 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F4752895BE680074B7C9 /* FerriteDB.xcdatamodeld */; }; 0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47A2895BFED0074B7C9 /* Source+CoreDataClass.swift */; }; 0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; }; @@ -56,7 +69,6 @@ 0CA148DB288903F000DE2211 /* NavView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C1288903F000DE2211 /* NavView.swift */; }; 0CA148DC288903F000DE2211 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C2288903F000DE2211 /* Assets.xcassets */; }; 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */; }; - 0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C4288903F000DE2211 /* RealDebridModels.swift */; }; 0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; }; 0CA148E0288903F000DE2211 /* FerriteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C7288903F000DE2211 /* FerriteApp.swift */; }; 0CA148E1288903F000DE2211 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C9288903F000DE2211 /* Collection.swift */; }; @@ -69,41 +81,68 @@ 0CA148E9288903F000DE2211 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D1288903F000DE2211 /* MainView.swift */; }; 0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D3288903F000DE2211 /* SearchResultsView.swift */; }; 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; }; + 0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23328C2658700616D3A /* LibraryView.swift */; }; + 0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23628C2660700616D3A /* HistoryView.swift */; }; + 0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23828C2660D00616D3A /* BookmarksView.swift */; }; + 0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */; }; + 0CA3B23D28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */; }; 0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; }; + 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; }; 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; }; + 0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; }; + 0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; }; + 0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 0CB6516728C5A5EC00DCA721 /* Introspect */; }; + 0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */; }; + 0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; }; 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; }; 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; }; 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; }; - 0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */; }; + 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; }; + 0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; }; + 0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; }; + 0CE66B3A28E640D200F69346 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE66B3928E640D200F69346 /* Backport.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0C0167DB29293FA900B65783 /* RealDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = ""; }; 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = ""; }; 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = ""; }; 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = ""; }; + 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryButtonView.swift; sourceTree = ""; }; 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = ""; }; 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = ""; }; 0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; - 0C32FB542890D1BF002BD219 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; 0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = ""; }; + 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = ""; }; + 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = ""; }; + 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultButtonView.swift; sourceTree = ""; }; + 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = ""; }; + 0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 0C44E2AC28D51C63007711AE /* BackupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManager.swift; sourceTree = ""; }; + 0C44E2AE28D52E8A007711AE /* BackupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsView.swift; sourceTree = ""; }; 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataClass.swift"; sourceTree = ""; }; 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = ""; }; + 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = ""; }; + 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = ""; }; 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRDView.swift; sourceTree = ""; }; - 0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchProgressView.swift; sourceTree = ""; }; 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = ""; }; 0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = ""; }; + 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.swift; sourceTree = ""; }; + 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = ""; }; 0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = ""; }; 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = ""; }; 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataProperties.swift"; sourceTree = ""; }; 0C78041C28BFB3EA001E8CA3 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceUpdateButtonView.swift; sourceTree = ""; }; - 0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledSourceView.swift; sourceTree = ""; }; - 0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceCatalogView.swift; sourceTree = ""; }; + 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledSourceButtonView.swift; sourceTree = ""; }; + 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceCatalogButtonView.swift; sourceTree = ""; }; 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataClass.swift"; sourceTree = ""; }; 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataProperties.swift"; sourceTree = ""; }; - 0C7D11FB28AA01E900ED92DB /* DynamicAccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicAccentColor.swift; sourceTree = ""; }; + 0C7C128528DAA3CD00381CD1 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 0C7D11FD28AA03FE00ED92DB /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; + 0C7ED14028D61BBA009E29AD /* BackupModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupModels.swift; sourceTree = ""; }; + 0C7ED14228D65518009E29AD /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; 0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB.xcdatamodel; sourceTree = ""; }; 0C84F47A2895BFED0074B7C9 /* Source+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataClass.swift"; sourceTree = ""; }; 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = ""; }; @@ -122,7 +161,6 @@ 0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = ""; }; 0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = ""; }; - 0CA148C4288903F000DE2211 /* RealDebridModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 3; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = ""; }; 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 0CA148C7288903F000DE2211 /* FerriteApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FerriteApp.swift; sourceTree = ""; }; 0CA148C9288903F000DE2211 /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; @@ -135,13 +173,26 @@ 0CA148D1288903F000DE2211 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 0CA148D3288903F000DE2211 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = ""; }; 0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 0CA3B23328C2658700616D3A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + 0CA3B23628C2660700616D3A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 0CA3B23828C2660D00616D3A /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; + 0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataClass.swift"; sourceTree = ""; }; + 0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataProperties.swift"; sourceTree = ""; }; 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = ""; }; + 0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; 0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = ""; }; + 0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = ""; }; + 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = ""; }; + 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = ""; }; 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = ""; }; 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; }; 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = ""; }; + 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = ""; }; + 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = ""; }; + 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = ""; }; + 0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -153,6 +204,7 @@ 0C64A4B4288903680079976D /* Base32 in Frameworks */, 0C4CFC462897030D00AD9FAD /* Regex in Frameworks */, 0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */, + 0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */, 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */, 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */, ); @@ -164,6 +216,10 @@ 0C0D50DE288DF72D0035ECC8 /* Classes */ = { isa = PBXGroup; children = ( + 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */, + 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */, + 0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */, + 0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */, 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */, 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */, 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */, @@ -185,21 +241,60 @@ 0C0D50E3288DFE6E0035ECC8 /* Models */ = { isa = PBXGroup; children = ( - 0CA148C4288903F000DE2211 /* RealDebridModels.swift */, - 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */, - 0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */, + 0C7ED14028D61BBA009E29AD /* BackupModels.swift */, 0C68135128BC1A7C00FAD890 /* GithubModels.swift */, + 0C0167DB29293FA900B65783 /* RealDebridModels.swift */, + 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */, + 0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */, + 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */, ); path = Models; sourceTree = ""; }; + 0C44E2A628D4DDC6007711AE /* Classes */ = { + isa = PBXGroup; + children = ( + 0C44E2A728D4DDDC007711AE /* Application.swift */, + ); + path = Classes; + sourceTree = ""; + }; + 0C44E2A928D4DFC4007711AE /* Modifiers */ = { + isa = PBXGroup; + children = ( + 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */, + 0CB6516228C5A57300DCA721 /* ConditionalId.swift */, + 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */, + 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */, + 0CB6516428C5A5D700DCA721 /* InlinedList.swift */, + ); + path = Modifiers; + sourceTree = ""; + }; + 0C44E2AA28D4E09B007711AE /* Buttons */ = { + isa = PBXGroup; + children = ( + 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */, + 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */, + 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */, + ); + path = Buttons; + sourceTree = ""; + }; + 0C44E2AB28D4E126007711AE /* SearchResultViews */ = { + isa = PBXGroup; + children = ( + 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */, + 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */, + ); + path = SearchResultViews; + sourceTree = ""; + }; 0C794B65289DAC9F00DD1CC8 /* SourceViews */ = { isa = PBXGroup; children = ( + 0C44E2AA28D4E09B007711AE /* Buttons */, 0C733286289C4C820058D1FE /* SourceSettingsView.swift */, - 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */, - 0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */, - 0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */, ); path = SourceViews; sourceTree = ""; @@ -207,6 +302,7 @@ 0CA0545C288F7CB200850554 /* SettingsViews */ = { isa = PBXGroup; children = ( + 0C44E2AE28D52E8A007711AE /* BackupsView.swift */, 0CA05456288EE58200850554 /* SettingsSourceListView.swift */, 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */, 0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */, @@ -224,6 +320,7 @@ 0C0D50E3288DFE6E0035ECC8 /* Models */, 0CA148EF2889061600DE2211 /* ViewModels */, 0CA148EE2889061200DE2211 /* Views */, + 0C44E2A628D4DDC6007711AE /* Classes */, 0CA148C8288903F000DE2211 /* Extensions */, 0CA148C5288903F000DE2211 /* Preview Content */, 0CA148C7288903F000DE2211 /* FerriteApp.swift */, @@ -236,11 +333,15 @@ 0CA148C0288903F000DE2211 /* CommonViews */ = { isa = PBXGroup; children = ( + 0C44E2A928D4DFC4007711AE /* Modifiers */, + 0CE66B3928E640D200F69346 /* Backport.swift */, + 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */, + 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */, + 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */, 0CA148C1288903F000DE2211 /* NavView.swift */, - 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */, - 0C32FB562890D1F2002BD219 /* ListRowViews.swift */, - 0C7D11FB28AA01E900ED92DB /* DynamicAccentColor.swift */, 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */, + 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */, + 0C32FB562890D1F2002BD219 /* ListRowViews.swift */, ); path = CommonViews; sourceTree = ""; @@ -258,10 +359,13 @@ children = ( 0CA148C9288903F000DE2211 /* Collection.swift */, 0CA148CA288903F000DE2211 /* Data.swift */, - 0CA148CB288903F000DE2211 /* Task.swift */, - 0C32FB542890D1BF002BD219 /* UIApplication.swift */, - 0C7D11FD28AA03FE00ED92DB /* View.swift */, + 0CA429F728C5098D000D0610 /* DateFormatter.swift */, + 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */, 0C78041C28BFB3EA001E8CA3 /* String.swift */, + 0CA148CB288903F000DE2211 /* Task.swift */, + 0C7D11FD28AA03FE00ED92DB /* View.swift */, + 0C7ED14228D65518009E29AD /* FileManager.swift */, + 0C7C128528DAA3CD00381CD1 /* URL.swift */, ); path = Extensions; sourceTree = ""; @@ -269,21 +373,22 @@ 0CA148EE2889061200DE2211 /* Views */ = { isa = PBXGroup; children = ( + 0CA3B23528C265FD00616D3A /* LibraryViews */, 0C794B65289DAC9F00DD1CC8 /* SourceViews */, 0CA148F02889062700DE2211 /* RepresentableViews */, 0CA148C0288903F000DE2211 /* CommonViews */, + 0C44E2AB28D4E126007711AE /* SearchResultViews */, 0CA0545C288F7CB200850554 /* SettingsViews */, - 0CA148D3288903F000DE2211 /* SearchResultsView.swift */, - 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */, - 0CA148D4288903F000DE2211 /* ContentView.swift */, 0CA148D1288903F000DE2211 /* MainView.swift */, + 0CA148D4288903F000DE2211 /* ContentView.swift */, + 0CA148D3288903F000DE2211 /* SearchResultsView.swift */, + 0CA3B23328C2658700616D3A /* LibraryView.swift */, + 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */, 0CA148BB288903F000DE2211 /* SettingsView.swift */, + 0C32FB522890D19D002BD219 /* AboutView.swift */, 0CA148BC288903F000DE2211 /* LoginWebView.swift */, 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, - 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */, - 0C32FB522890D19D002BD219 /* AboutView.swift */, - 0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */, ); path = Views; sourceTree = ""; @@ -296,6 +401,7 @@ 0CA148CF288903F000DE2211 /* ToastViewModel.swift */, 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */, 0CA05458288EE9E600850554 /* SourceManager.swift */, + 0C44E2AC28D51C63007711AE /* BackupManager.swift */, ); path = ViewModels; sourceTree = ""; @@ -311,12 +417,23 @@ 0CA148F12889066000DE2211 /* API */ = { isa = PBXGroup; children = ( - 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */, + 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, ); path = API; sourceTree = ""; }; + 0CA3B23528C265FD00616D3A /* LibraryViews */ = { + isa = PBXGroup; + children = ( + 0CA3B23828C2660D00616D3A /* BookmarksView.swift */, + 0CA3B23628C2660700616D3A /* HistoryView.swift */, + 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */, + 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */, + ); + path = LibraryViews; + sourceTree = ""; + }; 0CAF1C5F286F5C0D00296F86 = { isa = PBXGroup; children = ( @@ -366,6 +483,7 @@ 0C4CFC452897030D00AD9FAD /* Regex */, 0C7376EF28A97D1400D60918 /* SwiftUIX */, 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */, + 0CB6516728C5A5EC00DCA721 /* Introspect */, ); productName = Torrenter; productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */; @@ -402,6 +520,7 @@ 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */, 0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */, 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, + 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */; projectDirPath = ""; @@ -430,61 +549,86 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0C7ED14328D65518009E29AD /* FileManager.swift in Sources */, 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */, - 0C60B1EF28A1A00000E3FD7E /* SearchProgressView.swift in Sources */, + 0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */, 0C32FB532890D19D002BD219 /* AboutView.swift in Sources */, + 0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */, + 0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */, 0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */, + 0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */, 0CA148DB288903F000DE2211 /* NavView.swift in Sources */, + 0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */, 0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */, 0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */, 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */, 0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */, 0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */, + 0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */, + 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */, + 0CE66B3A28E640D200F69346 /* Backport.swift in Sources */, 0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */, - 0C794B6B289DACF100DD1CC8 /* SourceCatalogView.swift in Sources */, + 0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */, + 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */, 0CA148E9288903F000DE2211 /* MainView.swift in Sources */, 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, - 0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */, 0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */, + 0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */, + 0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */, 0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */, + 0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */, + 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */, + 0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */, + 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */, 0CA148E1288903F000DE2211 /* Collection.swift in Sources */, 0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */, - 0C794B69289DACC800DD1CC8 /* InstalledSourceView.swift in Sources */, + 0C794B69289DACC800DD1CC8 /* InstalledSourceButtonView.swift in Sources */, 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */, + 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, + 0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */, 0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */, 0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */, + 0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */, 0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */, 0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */, 0CA148E3288903F000DE2211 /* Task.swift in Sources */, 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */, 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */, - 0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */, + 0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */, 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */, 0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */, 0CA148E6288903F000DE2211 /* WebView.swift in Sources */, + 0CA3B23D28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift in Sources */, 0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */, + 0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */, 0CA148E2288903F000DE2211 /* Data.swift in Sources */, + 0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */, 0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */, - 0C7D11FC28AA01E900ED92DB /* DynamicAccentColor.swift in Sources */, + 0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */, 0CA05459288EE9E600850554 /* SourceManager.swift in Sources */, 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */, + 0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */, 0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */, 0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */, 0CA05457288EE58200850554 /* SettingsSourceListView.swift in Sources */, 0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */, 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */, - 0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */, 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, + 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */, 0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */, 0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */, 0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */, 0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */, + 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */, + 0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */, 0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */, 0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */, 0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */, + 0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */, + 0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */, 0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */, 0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */, 0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */, @@ -615,7 +759,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_TEAM = 8A74DBQ6S3; ENABLE_PREVIEWS = YES; @@ -623,6 +767,7 @@ INFOPLIST_FILE = Ferrite/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Ferrite; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -633,10 +778,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.4.0; + MARKETING_VERSION = 0.5.0; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -648,7 +794,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_TEAM = 8A74DBQ6S3; ENABLE_PREVIEWS = YES; @@ -656,6 +802,7 @@ INFOPLIST_FILE = Ferrite/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Ferrite; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -666,10 +813,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.4.0; + MARKETING_VERSION = 0.5.0; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -747,6 +895,14 @@ minimumVersion = 2.0.0; }; }; + 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + branch = master; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -780,6 +936,11 @@ package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; + 0CB6516728C5A5EC00DCA721 /* Introspect */ = { + isa = XCSwiftPackageProductDependency; + package = 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = Introspect; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index fb4e8e3..ecefd2a 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -14,6 +14,7 @@ public enum RealDebridError: Error { case InvalidResponse case InvalidToken case EmptyData + case EmptyTorrents case FailedRequest(description: String) case AuthQuery(description: String) } @@ -248,9 +249,22 @@ public class RealDebrid { } } - availableHashes.append(RealDebridIA(hash: hash, files: files, batches: batches)) + // TTL: 5 minutes + availableHashes.append( + RealDebridIA( + hash: hash, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files, + batches: batches + ) + ) } else { - availableHashes.append(RealDebridIA(hash: hash)) + availableHashes.append( + RealDebridIA( + hash: hash, + expiryTimeStamp: Date().timeIntervalSince1970 + 300 + ) + ) } } @@ -294,21 +308,33 @@ public class RealDebrid { try await performRequest(request: &request, requestName: #function) } - // Fetches the info of a torrent + // Gets the info of a torrent from a given ID public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!) let data = try await performRequest(request: &request, requestName: #function) let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data) - // Error out if no index is provided - if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1] { + // Let the user know if a torrent is downloading + if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" { return torrentLink + } else if rawResponse.status == "downloading" || rawResponse.status == "queued" { + throw RealDebridError.EmptyTorrents } else { throw RealDebridError.EmptyData } } + // Gets the user's torrent library + public func userTorrents() async throws -> [UserTorrentsResponse] { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data) + + return rawResponse + } + // Deletes a torrent download from RD public func deleteTorrent(debridID: String) async throws { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!) @@ -333,4 +359,14 @@ public class RealDebrid { return rawResponse.download } + + // Gets the user's downloads + public func userDownloads() async throws -> [UserDownloadsResponse] { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data) + + return rawResponse + } } diff --git a/Ferrite/Extensions/UIApplication.swift b/Ferrite/Classes/Application.swift similarity index 64% rename from Ferrite/Extensions/UIApplication.swift rename to Ferrite/Classes/Application.swift index 3490ab4..6ebdbab 100644 --- a/Ferrite/Extensions/UIApplication.swift +++ b/Ferrite/Classes/Application.swift @@ -1,14 +1,17 @@ // -// UIApplication.swift +// Application.swift // Ferrite // -// Created by Brian Dashore on 7/26/22. +// Created by Brian Dashore on 9/16/22. +// +// A thread-safe UIApplication alternative for specifying app properties // -import SwiftUI +import Foundation + +public class Application { + static let shared = Application() -// Extensions to get the version/build number for AboutView -extension UIApplication { var appVersion: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" } diff --git a/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift new file mode 100644 index 0000000..dcd0f86 --- /dev/null +++ b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift @@ -0,0 +1,25 @@ +// +// Bookmark+CoreDataClass.swift +// Ferrite +// +// Created by Brian Dashore on 9/2/22. +// +// + +import CoreData +import Foundation + +@objc(Bookmark) +public class Bookmark: NSManagedObject { + func toSearchResult() -> SearchResult { + SearchResult( + title: title, + source: source, + size: size, + magnetLink: magnetLink, + magnetHash: magnetHash, + seeders: seeders, + leechers: leechers + ) + } +} diff --git a/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift new file mode 100644 index 0000000..36f2e9e --- /dev/null +++ b/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift @@ -0,0 +1,27 @@ +// +// Bookmark+CoreDataProperties.swift +// Ferrite +// +// Created by Brian Dashore on 9/3/22. +// +// + +import CoreData +import Foundation + +public extension Bookmark { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Bookmark") + } + + @NSManaged var leechers: String? + @NSManaged var magnetHash: String? + @NSManaged var magnetLink: String? + @NSManaged var seeders: String? + @NSManaged var size: String? + @NSManaged var source: String + @NSManaged var title: String? + @NSManaged var orderNum: Int16 +} + +extension Bookmark: Identifiable {} diff --git a/Ferrite/DataManagement/Classes/History+CoreDataClass.swift b/Ferrite/DataManagement/Classes/History+CoreDataClass.swift new file mode 100644 index 0000000..18677e1 --- /dev/null +++ b/Ferrite/DataManagement/Classes/History+CoreDataClass.swift @@ -0,0 +1,13 @@ +// +// History+CoreDataClass.swift +// Ferrite +// +// Created by Brian Dashore on 9/4/22. +// +// + +import CoreData +import Foundation + +@objc(History) +public class History: NSManagedObject {} diff --git a/Ferrite/DataManagement/Classes/History+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/History+CoreDataProperties.swift new file mode 100644 index 0000000..cc8ad58 --- /dev/null +++ b/Ferrite/DataManagement/Classes/History+CoreDataProperties.swift @@ -0,0 +1,46 @@ +// +// History+CoreDataProperties.swift +// Ferrite +// +// Created by Brian Dashore on 9/4/22. +// +// + +import CoreData +import Foundation + +public extension History { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "History") + } + + @NSManaged var date: Date? + @NSManaged var dateString: String? + @NSManaged var entries: NSSet? + + internal var entryArray: [HistoryEntry] { + let entrySet = entries as? Set ?? [] + + return entrySet.sorted { + $0.timeStamp > $1.timeStamp + } + } +} + +// MARK: Generated accessors for entries + +public extension History { + @objc(addEntriesObject:) + @NSManaged func addToEntries(_ value: HistoryEntry) + + @objc(removeEntriesObject:) + @NSManaged func removeFromEntries(_ value: HistoryEntry) + + @objc(addEntries:) + @NSManaged func addToEntries(_ values: NSSet) + + @objc(removeEntries:) + @NSManaged func removeFromEntries(_ values: NSSet) +} + +extension History: Identifiable {} diff --git a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents index 99cfeeb..b89a6fc 100644 --- a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents +++ b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents @@ -1,5 +1,28 @@ - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ferrite/DataManagement/PersistenceController.swift b/Ferrite/DataManagement/PersistenceController.swift index de19701..6983d0a 100644 --- a/Ferrite/DataManagement/PersistenceController.swift +++ b/Ferrite/DataManagement/PersistenceController.swift @@ -7,9 +7,21 @@ import CoreData +enum HistoryDeleteRange { + case day + case week + case month + case allTime +} + +enum HistoryDeleteError: Error { + case noDate(String) + case unknown(String) +} + // No iCloud until finalized sources struct PersistenceController { - static var shared = PersistenceController() + static let shared = PersistenceController() // Coredata storage let container: NSPersistentContainer @@ -33,7 +45,7 @@ struct PersistenceController { description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) container.loadPersistentStores { _, error in - if let error = error { + if let error { fatalError("CoreData init error: \(error)") } } @@ -78,4 +90,130 @@ struct PersistenceController { container.viewContext.delete(object) save() } + + func createBookmark(_ bookmarkJson: BookmarkJson) { + let bookmarkRequest = Bookmark.fetchRequest() + bookmarkRequest.predicate = NSPredicate( + format: "source == %@ AND title == %@ AND magnetLink == %@", + bookmarkJson.source, + bookmarkJson.title ?? "", + bookmarkJson.magnetLink ?? "" + ) + + if (try? backgroundContext.fetch(bookmarkRequest).first) != nil { + return + } + + let newBookmark = Bookmark(context: backgroundContext) + + newBookmark.title = bookmarkJson.title + newBookmark.source = bookmarkJson.source + newBookmark.magnetHash = bookmarkJson.magnetHash + newBookmark.magnetLink = bookmarkJson.magnetLink + newBookmark.seeders = bookmarkJson.seeders + newBookmark.leechers = bookmarkJson.leechers + } + + func createHistory(entryJson: HistoryEntryJson, date: Double?) { + let historyDate = date.map { Date(timeIntervalSince1970: $0) } ?? Date() + let historyDateString = DateFormatter.historyDateFormatter.string(from: historyDate) + + let newHistoryEntry = HistoryEntry(context: backgroundContext) + + newHistoryEntry.source = entryJson.source + newHistoryEntry.name = entryJson.name + newHistoryEntry.url = entryJson.url + newHistoryEntry.subName = entryJson.subName + newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970 + + let historyRequest = History.fetchRequest() + historyRequest.predicate = NSPredicate(format: "dateString = %@", historyDateString) + + // Safely add entries to a parent history if it exists + if var histories = try? backgroundContext.fetch(historyRequest) { + for (i, history) in histories.enumerated() { + let existingEntries = history.entryArray.filter { $0.url == newHistoryEntry.url && $0.name == newHistoryEntry.name } + + if !existingEntries.isEmpty { + for entry in existingEntries { + PersistenceController.shared.delete(entry, context: backgroundContext) + } + } + + if history.entryArray.isEmpty { + PersistenceController.shared.delete(history, context: backgroundContext) + histories.remove(at: i) + } + } + + newHistoryEntry.parentHistory = histories.first ?? History(context: backgroundContext) + } else { + newHistoryEntry.parentHistory = History(context: backgroundContext) + } + + newHistoryEntry.parentHistory?.dateString = historyDateString + newHistoryEntry.parentHistory?.date = historyDate + } + + func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? { + if range == .allTime { + return nil + } + + var components = Calendar.current.dateComponents([.day, .month, .year], from: Date()) + components.hour = 0 + components.minute = 0 + components.second = 0 + + guard let today = Calendar.current.date(from: components) else { + return nil + } + + var offsetComponents = DateComponents(day: 1) + guard let tomorrow = Calendar.current.date(byAdding: offsetComponents, to: today) else { + return nil + } + + switch range { + case .week: + offsetComponents.day = -7 + case .month: + offsetComponents.day = -28 + default: + break + } + + guard var offsetDate = Calendar.current.date(byAdding: offsetComponents, to: today) else { + return nil + } + + if TimeZone.current.isDaylightSavingTime(for: offsetDate) { + offsetDate = offsetDate.addingTimeInterval(3600) + } + + let predicate = NSPredicate(format: "date >= %@ && date < %@", range == .day ? today as NSDate : offsetDate as NSDate, tomorrow as NSDate) + + return predicate + } + + // Always use the background context to batch delete + // Merge changes into both contexts to update views + func batchDeleteHistory(range: HistoryDeleteRange) throws { + let predicate = getHistoryPredicate(range: range) + + let fetchRequest = NSFetchRequest(entityName: "History") + + if let predicate { + fetchRequest.predicate = predicate + } else if range != .allTime { + throw HistoryDeleteError.noDate("No history date range was provided and you weren't trying to clear everything! Try relaunching the app?") + } + + let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + + batchDeleteRequest.resultType = .resultTypeObjectIDs + let result = try backgroundContext.execute(batchDeleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []] + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [container.viewContext, backgroundContext]) + } } diff --git a/Ferrite/Extensions/DateFormatter.swift b/Ferrite/Extensions/DateFormatter.swift new file mode 100644 index 0000000..4177912 --- /dev/null +++ b/Ferrite/Extensions/DateFormatter.swift @@ -0,0 +1,17 @@ +// +// DateFormatter.swift +// Ferrite +// +// Created by Brian Dashore on 9/4/22. +// + +import Foundation + +extension DateFormatter { + static let historyDateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "ddMMyyyy" + + return df + }() +} diff --git a/Ferrite/Extensions/FileManager.swift b/Ferrite/Extensions/FileManager.swift new file mode 100644 index 0000000..33656b6 --- /dev/null +++ b/Ferrite/Extensions/FileManager.swift @@ -0,0 +1,14 @@ +// +// FileManager.swift +// Ferrite +// +// Created by Brian Dashore on 9/17/22. +// + +import Foundation + +extension FileManager { + var appDirectory: URL { + urls(for: .documentDirectory, in: .userDomainMask)[0] + } +} diff --git a/Ferrite/Extensions/NotificationCenter.swift b/Ferrite/Extensions/NotificationCenter.swift new file mode 100644 index 0000000..7bc0db5 --- /dev/null +++ b/Ferrite/Extensions/NotificationCenter.swift @@ -0,0 +1,14 @@ +// +// NotificationCenter.swift +// Ferrite +// +// Created by Brian Dashore on 9/3/22. +// + +import Foundation + +extension Notification.Name { + static var didDeleteBookmark: Notification.Name { + Notification.Name("Deleted bookmark") + } +} diff --git a/Ferrite/Extensions/URL.swift b/Ferrite/Extensions/URL.swift new file mode 100644 index 0000000..8fd8a41 --- /dev/null +++ b/Ferrite/Extensions/URL.swift @@ -0,0 +1,29 @@ +// +// URL.swift +// Ferrite +// +// Created by Brian Dashore on 9/20/22. +// + +import Foundation + +extension URL { + // From https://github.com/Aidoku/Aidoku/blob/main/Shared/Extensions/FileManager.swift + // Used for FileManager + var contentsByDateAdded: [URL] { + if let urls = try? FileManager.default.contentsOfDirectory( + at: self, + includingPropertiesForKeys: [.contentModificationDateKey] + ) { + return urls.sorted { + ((try? $0.resourceValues(forKeys: [.addedToDirectoryDateKey]))?.addedToDirectoryDate ?? Date.distantPast) + > + ((try? $1.resourceValues(forKeys: [.addedToDirectoryDateKey]))?.addedToDirectoryDate ?? Date.distantPast) + } + } + + let contents = try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil) + + return contents ?? [] + } +} diff --git a/Ferrite/Extensions/View.swift b/Ferrite/Extensions/View.swift index 7b7e4cc..b47adf9 100644 --- a/Ferrite/Extensions/View.swift +++ b/Ferrite/Extensions/View.swift @@ -5,12 +5,45 @@ // Created by Brian Dashore on 8/15/22. // +import Introspect import SwiftUI extension View { + // MARK: Custom introspect functions + + func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View { + inject(UIKitIntrospectionView( + selector: { introspectionView in + guard let viewHost = Introspect.findViewHost(from: introspectionView) else { + return nil + } + return Introspect.previousSibling(containing: UICollectionView.self, from: viewHost) + }, + customize: customize + )) + } + // MARK: Modifiers - func dynamicAccentColor(_ color: Color) -> some View { - modifier(DynamicAccentColor(color: color)) + func conditionalContextMenu(id: some Hashable, + @ViewBuilder _ internalContent: @escaping () -> some View) -> some View + { + modifier(ConditionalContextMenu(internalContent, id: id)) + } + + func conditionalId(_ id: some Hashable) -> some View { + modifier(ConditionalId(id: id)) + } + + func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View { + modifier(DisabledAppearance(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation)) + } + + func disableInteraction(_ disabled: Bool) -> some View { + modifier(DisableInteraction(disabled: disabled)) + } + + func inlinedList() -> some View { + modifier(InlinedList()) } } diff --git a/Ferrite/FerriteApp.swift b/Ferrite/FerriteApp.swift index 4d486fd..3f26413 100644 --- a/Ferrite/FerriteApp.swift +++ b/Ferrite/FerriteApp.swift @@ -16,6 +16,7 @@ struct FerriteApp: App { @StateObject var debridManager: DebridManager = .init() @StateObject var navModel: NavigationViewModel = .init() @StateObject var sourceManager: SourceManager = .init() + @StateObject var backupManager: BackupManager = .init() var body: some Scene { WindowGroup { @@ -24,6 +25,7 @@ struct FerriteApp: App { scrapingModel.toastModel = toastModel debridManager.toastModel = toastModel sourceManager.toastModel = toastModel + backupManager.toastModel = toastModel navModel.toastModel = toastModel } .environmentObject(debridManager) @@ -31,6 +33,7 @@ struct FerriteApp: App { .environmentObject(toastModel) .environmentObject(navModel) .environmentObject(sourceManager) + .environmentObject(backupManager) .environment(\.managedObjectContext, persistenceController.container.viewContext) } } diff --git a/Ferrite/Info.plist b/Ferrite/Info.plist index 3eba462..a289566 100644 --- a/Ferrite/Info.plist +++ b/Ferrite/Info.plist @@ -2,6 +2,19 @@ + CFBundleDocumentTypes + + + CFBundleTypeName + Ferrite Backup + LSHandlerRank + Owner + LSItemContentTypes + + me.kingbri.Ferrite.feb + + + NSAppTransportSecurity NSAllowsArbitraryLoads @@ -9,5 +22,27 @@ UILaunchScreen + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.json + + UTTypeDescription + Ferrite Backup + UTTypeIconFiles + + UTTypeIdentifier + me.kingbri.Ferrite.feb + UTTypeTagSpecification + + public.filename-extension + + feb + + + + diff --git a/Ferrite/Models/BackupModels.swift b/Ferrite/Models/BackupModels.swift new file mode 100644 index 0000000..94b3b27 --- /dev/null +++ b/Ferrite/Models/BackupModels.swift @@ -0,0 +1,42 @@ +// +// BackupModels.swift +// Ferrite +// +// Created by Brian Dashore on 9/17/22. +// + +import Foundation + +public struct Backup: Codable { + var bookmarks: [BookmarkJson]? + var history: [HistoryJson]? + var sourceNames: [String]? + var sourceLists: [SourceListBackupJson]? +} + +// MARK: - CoreData translation + +typealias BookmarkJson = SearchResult + +// Date is an epoch timestamp +struct HistoryJson: Codable { + let dateString: String? + let date: Double + let entries: [HistoryEntryJson] +} + +struct HistoryEntryJson: Codable { + let name: String + let subName: String? + let url: String + let timeStamp: Double? + let source: String? +} + +// Differs from SourceListJson +struct SourceListBackupJson: Codable { + let name: String + let author: String + let id: String + let urlString: String +} diff --git a/Ferrite/Models/GithubModels.swift b/Ferrite/Models/GithubModels.swift index 575dba5..fb4a066 100644 --- a/Ferrite/Models/GithubModels.swift +++ b/Ferrite/Models/GithubModels.swift @@ -7,7 +7,7 @@ import Foundation -public struct GithubRelease: Codable, Hashable { +public struct GithubRelease: Codable, Hashable, Sendable { let htmlUrl: String let tagName: String diff --git a/Ferrite/Models/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift index a9860ec..b58de72 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -2,7 +2,7 @@ // RealDebridModels.swift // Ferrite // -// Created by Brian Dashore on 7/5/22. +// Created by Brian Dashore on 11/19/22. // // Structures generated from Quicktype @@ -10,7 +10,7 @@ import Foundation // MARK: - device code endpoint -public struct DeviceCodeResponse: Codable { +public struct DeviceCodeResponse: Codable, Sendable { let deviceCode, userCode: String let interval, expiresIn: Int let verificationURL, directVerificationURL: String @@ -27,7 +27,7 @@ public struct DeviceCodeResponse: Codable { // MARK: - device credentials endpoint -public struct DeviceCredentialsResponse: Codable { +public struct DeviceCredentialsResponse: Codable, Sendable { let clientID, clientSecret: String? enum CodingKeys: String, CodingKey { @@ -38,7 +38,7 @@ public struct DeviceCredentialsResponse: Codable { // MARK: - token endpoint -public struct TokenResponse: Codable { +public struct TokenResponse: Codable, Sendable { let accessToken: String let expiresIn: Int let refreshToken, tokenType: String @@ -54,7 +54,7 @@ public struct TokenResponse: Codable { // MARK: - instantAvailability endpoint // Thanks Skitty! -public struct InstantAvailabilityResponse: Codable { +public struct InstantAvailabilityResponse: Codable, Sendable { var data: InstantAvailabilityData? public init(from decoder: Decoder) throws { @@ -66,39 +66,40 @@ public struct InstantAvailabilityResponse: Codable { } } -struct InstantAvailabilityData: Codable { +// MARK: - Instant Availability client side structures + +struct InstantAvailabilityData: Codable, Sendable { var rd: [[String: InstantAvailabilityInfo]] } -struct InstantAvailabilityInfo: Codable { +struct InstantAvailabilityInfo: Codable, Sendable { var filename: String var filesize: Int } -// MARK: - Instant Availability client side structures - -public struct RealDebridIA: Codable, Hashable { +public struct RealDebridIA: Codable, Hashable, Sendable { let hash: String + let expiryTimeStamp: Double var files: [RealDebridIAFile] = [] var batches: [RealDebridIABatch] = [] } -public struct RealDebridIABatch: Codable, Hashable { +public struct RealDebridIABatch: Codable, Hashable, Sendable { let files: [RealDebridIABatchFile] } -public struct RealDebridIABatchFile: Codable, Hashable { +public struct RealDebridIABatchFile: Codable, Hashable, Sendable { let id: Int let fileName: String } -public struct RealDebridIAFile: Codable, Hashable { +public struct RealDebridIAFile: Codable, Hashable, Sendable { let name: String let batchIndex: Int let batchFileIndex: Int } -public enum RealDebridIAStatus: Codable, Hashable { +public enum RealDebridIAStatus: Codable, Hashable, Sendable { case full case partial case none @@ -106,14 +107,14 @@ public enum RealDebridIAStatus: Codable, Hashable { // MARK: - addMagnet endpoint -public struct AddMagnetResponse: Codable { +public struct AddMagnetResponse: Codable, Sendable { let id: String let uri: String } // MARK: - torrentInfo endpoint -struct TorrentInfoResponse: Codable { +struct TorrentInfoResponse: Codable, Sendable { let id, filename, originalFilename, hash: String let bytes, originalBytes: Int let host: String @@ -121,27 +122,41 @@ struct TorrentInfoResponse: Codable { let status, added: String let files: [TorrentInfoFile] let links: [String] - let ended: String + let ended: String? + let speed: Int? + let seeders: Int? enum CodingKeys: String, CodingKey { case id, filename case originalFilename = "original_filename" case hash, bytes case originalBytes = "original_bytes" - case host, split, progress, status, added, files, links, ended + case host, split, progress, status, added, files, links, ended, speed, seeders } } -struct TorrentInfoFile: Codable { +struct TorrentInfoFile: Codable, Sendable { let id: Int let path: String let bytes, selected: Int } +public struct UserTorrentsResponse: Codable, Sendable { + let id, filename, hash: String + let bytes: Int + let host: String + let split, progress: Int + let status, added: String + let links: [String] + let speed, seeders: Int? + let ended: String? +} + // MARK: - unrestrictLink endpoint -struct UnrestrictLinkResponse: Codable { - let id, filename, mimeType: String +struct UnrestrictLinkResponse: Codable, Sendable { + let id, filename: String + let mimeType: String? let filesize: Int let link: String let host: String @@ -156,3 +171,24 @@ struct UnrestrictLinkResponse: Codable { case chunks, crc, download, streamable } } + +// MARK: - User downloads list + +public struct UserDownloadsResponse: Codable, Sendable { + let id, filename: String + let mimeType: String? + let filesize: Int + let link: String + let host: String + let hostIcon: String + let chunks: Int + let download: String + let streamable: Int + let generated: String + + enum CodingKeys: String, CodingKey { + case id, filename, mimeType, filesize, link, host + case hostIcon = "host_icon" + case chunks, download, streamable, generated + } +} diff --git a/Ferrite/Models/SearchModels.swift b/Ferrite/Models/SearchModels.swift new file mode 100644 index 0000000..4f7fb59 --- /dev/null +++ b/Ferrite/Models/SearchModels.swift @@ -0,0 +1,18 @@ +// +// SearchModels.swift +// Ferrite +// +// Created by Brian Dashore on 9/2/22. +// + +import Foundation + +public struct SearchResult: Hashable, Codable, Sendable { + let title: String? + let source: String + let size: String? + let magnetLink: String? + let magnetHash: String? + let seeders: String? + let leechers: String? +} diff --git a/Ferrite/Models/SourceModels.swift b/Ferrite/Models/SourceModels.swift index 558af84..8cf3221 100644 --- a/Ferrite/Models/SourceModels.swift +++ b/Ferrite/Models/SourceModels.swift @@ -7,18 +7,18 @@ import Foundation -public enum ApiCredentialResponseType: String, Codable, Hashable { +public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable { case json case text } -public struct SourceListJson: Codable { +public struct SourceListJson: Codable, Sendable { let name: String let author: String var sources: [SourceJson] } -public struct SourceJson: Codable, Hashable { +public struct SourceJson: Codable, Hashable, Sendable { let name: String let version: Int16 let minVersion: String? @@ -34,20 +34,20 @@ public struct SourceJson: Codable, Hashable { let htmlParser: SourceHtmlParserJson? } -public enum SourcePreferredParser: Int16, CaseIterable { +public enum SourcePreferredParser: Int16, CaseIterable, Sendable { // case none = 0 case scraping = 1 case rss = 2 case siteApi = 3 } -public struct SourceApiJson: Codable, Hashable { +public struct SourceApiJson: Codable, Hashable, Sendable { let apiUrl: String? let clientId: SourceApiCredentialJson? let clientSecret: SourceApiCredentialJson? } -public struct SourceApiCredentialJson: Codable, Hashable { +public struct SourceApiCredentialJson: Codable, Hashable, Sendable { let query: String? let value: String? let dynamic: Bool? @@ -56,7 +56,7 @@ public struct SourceApiCredentialJson: Codable, Hashable { let expiryLength: Double? } -public struct SourceJsonParserJson: Codable, Hashable { +public struct SourceJsonParserJson: Codable, Hashable, Sendable { let searchUrl: String let results: String? let subResults: String? @@ -67,7 +67,7 @@ public struct SourceJsonParserJson: Codable, Hashable { let sl: SourceSLJson? } -public struct SourceRssParserJson: Codable, Hashable { +public struct SourceRssParserJson: Codable, Hashable, Sendable { let rssUrl: String? let searchUrl: String let items: String @@ -78,7 +78,7 @@ public struct SourceRssParserJson: Codable, Hashable { let sl: SourceSLJson? } -public struct SourceHtmlParserJson: Codable, Hashable { +public struct SourceHtmlParserJson: Codable, Hashable, Sendable { let searchUrl: String let rows: String let magnet: SourceMagnetJson @@ -87,21 +87,21 @@ public struct SourceHtmlParserJson: Codable, Hashable { let sl: SourceSLJson? } -public struct SouceComplexQueryJson: Codable, Hashable { +public struct SouceComplexQueryJson: Codable, Hashable, Sendable { let query: String let discriminator: String? let attribute: String? let regex: String? } -public struct SourceMagnetJson: Codable, Hashable { +public struct SourceMagnetJson: Codable, Hashable, Sendable { let query: String let attribute: String let regex: String? let externalLinkQuery: String? } -public struct SourceSLJson: Codable, Hashable { +public struct SourceSLJson: Codable, Hashable, Sendable { let seeders: String? let leechers: String? let combined: String? diff --git a/Ferrite/ViewModels/BackupManager.swift b/Ferrite/ViewModels/BackupManager.swift new file mode 100644 index 0000000..045ecf9 --- /dev/null +++ b/Ferrite/ViewModels/BackupManager.swift @@ -0,0 +1,220 @@ +// +// BackupManager.swift +// Ferrite +// +// Created by Brian Dashore on 9/16/22. +// + +import Foundation + +public class BackupManager: ObservableObject { + var toastModel: ToastViewModel? + + @Published var showRestoreAlert = false + @Published var showRestoreCompletedAlert = false + + @Published var backupUrls: [URL] = [] + @Published var backupSourceNames: [String] = [] + @Published var selectedBackupUrl: URL? + + func createBackup() { + var backup = Backup() + let backgroundContext = PersistenceController.shared.backgroundContext + + let bookmarkRequest = Bookmark.fetchRequest() + if let fetchedBookmarks = try? backgroundContext.fetch(bookmarkRequest) { + backup.bookmarks = fetchedBookmarks.compactMap { + BookmarkJson( + title: $0.title, + source: $0.source, + size: $0.size, + magnetLink: $0.magnetLink, + magnetHash: $0.magnetHash, + seeders: $0.seeders, + leechers: $0.leechers + ) + } + } + + let historyRequest = History.fetchRequest() + if let fetchedHistory = try? backgroundContext.fetch(historyRequest) { + backup.history = fetchedHistory.compactMap { history in + if history.entries == nil { + return nil + } else { + return HistoryJson( + dateString: history.dateString, + date: history.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970, + entries: history.entryArray.compactMap { entry in + if let name = entry.name, let url = entry.url { + return HistoryEntryJson( + name: name, + subName: entry.subName, + url: url, + timeStamp: entry.timeStamp, + source: entry.source + ) + } else { + return nil + } + } + ) + } + } + } + + let sourceRequest = Source.fetchRequest() + if let sources = try? backgroundContext.fetch(sourceRequest) { + backup.sourceNames = sources.map(\.name) + } + + let sourceListRequest = SourceList.fetchRequest() + if let sourceLists = try? backgroundContext.fetch(sourceListRequest) { + backup.sourceLists = sourceLists.map { + SourceListBackupJson( + name: $0.name, + author: $0.author, + id: $0.id.uuidString, + urlString: $0.urlString + ) + } + } + + do { + let encodedJson = try JSONEncoder().encode(backup) + let backupsPath = FileManager.default.appDirectory.appendingPathComponent("Backups") + if !FileManager.default.fileExists(atPath: backupsPath.path) { + try FileManager.default.createDirectory(atPath: backupsPath.path, withIntermediateDirectories: true, attributes: nil) + } + + let snapshot = Int(Date().timeIntervalSince1970.rounded()) + let writeUrl = backupsPath.appendingPathComponent("Ferrite-backup-\(snapshot).feb") + + try encodedJson.write(to: writeUrl) + backupUrls.append(writeUrl) + } catch { + print(error) + } + } + + // Backup is in local documents directory, so no need to restore it from the shared URL + func restoreBackup() { + guard let backupUrl = selectedBackupUrl else { + Task { + await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.") + } + + return + } + + let backgroundContext = PersistenceController.shared.backgroundContext + + do { + let file = try Data(contentsOf: backupUrl) + + let backup = try JSONDecoder().decode(Backup.self, from: file) + + if let bookmarks = backup.bookmarks { + for bookmark in bookmarks { + PersistenceController.shared.createBookmark(bookmark) + } + } + + if let storedHistories = backup.history { + for storedHistory in storedHistories { + for storedEntry in storedHistory.entries { + PersistenceController.shared.createHistory(entryJson: storedEntry, date: storedHistory.date) + } + } + } + + if let storedLists = backup.sourceLists { + for list in storedLists { + let sourceListRequest = SourceList.fetchRequest() + let urlPredicate = NSPredicate(format: "urlString == %@", list.urlString) + let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", list.author, list.name) + sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate]) + sourceListRequest.fetchLimit = 1 + + if (try? backgroundContext.fetch(sourceListRequest).first) != nil { + continue + } + + let newSourceList = SourceList(context: backgroundContext) + newSourceList.name = list.name + newSourceList.urlString = list.urlString + newSourceList.id = UUID(uuidString: list.id) ?? UUID() + newSourceList.author = list.author + } + } + + backupSourceNames = backup.sourceNames ?? [] + + PersistenceController.shared.save(backgroundContext) + + // if iOS 14 is available, sleep to prevent any issues with alerts + if #available(iOS 15, *) { + showRestoreCompletedAlert.toggle() + } else { + Task { + try? await Task.sleep(seconds: 0.1) + + Task { @MainActor in + showRestoreCompletedAlert.toggle() + } + } + } + } catch { + Task { + await toastModel?.updateToastDescription("Backup restore: \(error)") + } + } + } + + // Remove the backup from files and then the list + // Removes an index if it's provided + func removeBackup(backupUrl: URL, index: Int?) { + do { + try FileManager.default.removeItem(at: backupUrl) + + if let index { + backupUrls.remove(at: index) + } else { + backupUrls.removeAll(where: { $0 == backupUrl }) + } + } catch { + Task { + await toastModel?.updateToastDescription("Backup removal: \(error)") + } + } + } + + func copyBackup(backupUrl: URL) { + let backupSecured = backupUrl.startAccessingSecurityScopedResource() + + defer { + if backupSecured { + backupUrl.stopAccessingSecurityScopedResource() + } + } + + let backupsPath = FileManager.default.appDirectory.appendingPathComponent("Backups") + let localBackupPath = backupsPath.appendingPathComponent(backupUrl.lastPathComponent) + + do { + if FileManager.default.fileExists(atPath: localBackupPath.path) { + try FileManager.default.removeItem(at: localBackupPath) + } else if !FileManager.default.fileExists(atPath: backupsPath.path) { + try FileManager.default.createDirectory(atPath: backupsPath.path, withIntermediateDirectories: true, attributes: nil) + } + + try FileManager.default.copyItem(at: backupUrl, to: localBackupPath) + + selectedBackupUrl = localBackupPath + } catch { + Task { + await toastModel?.updateToastDescription("Backup copy: \(error)") + } + } + } +} diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index b1a05a9..5758b0d 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -10,17 +10,18 @@ import SwiftUI @MainActor public class DebridManager: ObservableObject { - // UI Variables + // Linked classes var toastModel: ToastViewModel? + let realDebrid: RealDebrid = .init() + + // UI Variables @Published var showWebView: Bool = false @Published var showLoadingProgress: Bool = false // Service agnostic variables - @Published var currentDebridTask: Task? + var currentDebridTask: Task? // RealDebrid auth variables - let realDebrid: RealDebrid = .init() - @Published var realDebridEnabled: Bool = false { didSet { UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled") @@ -28,31 +29,47 @@ public class DebridManager: ObservableObject { } @Published var realDebridAuthProcessing: Bool = false - @Published var realDebridAuthUrl: String = "" + var realDebridAuthUrl: String = "" // RealDebrid fetch variables - @Published var realDebridHashes: [RealDebridIA] = [] - @Published var realDebridDownloadUrl: String = "" - @Published var selectedRealDebridItem: RealDebridIA? - @Published var selectedRealDebridFile: RealDebridIAFile? + @Published var realDebridIAValues: [RealDebridIA] = [] + var realDebridDownloadUrl: String = "" + + @Published var showDeleteAlert: Bool = false + + // TODO: Switch to an individual item based sheet system to remove these variables + var selectedRealDebridItem: RealDebridIA? + var selectedRealDebridFile: RealDebridIAFile? + var selectedRealDebridID: String? init() { realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled") } - public func populateDebridHashes(_ searchResults: [SearchResult]) async { - var hashes: [String] = [] - - for result in searchResults { - if let hash = result.magnetHash { - hashes.append(hash) - } - } - + public func populateDebridHashes(_ resultHashes: [String]) async { do { - let debridHashes = try await realDebrid.instantAvailability(magnetHashes: hashes) + let now = Date() - realDebridHashes = debridHashes + // If a hash isn't found in the IA, update it + // If the hash is expired, remove it and update it + let sendHashes = resultHashes.filter { hash in + if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == hash }) { + if now.timeIntervalSince1970 > realDebridIAValues[IAIndex].expiryTimeStamp { + realDebridIAValues.remove(at: IAIndex) + return true + } else { + return false + } + } else { + return true + } + } + + if !sendHashes.isEmpty { + let fetchedIAValues = try await realDebrid.instantAvailability(magnetHashes: sendHashes) + + realDebridIAValues += fetchedIAValues + } } catch { let error = error as NSError @@ -65,11 +82,11 @@ public class DebridManager: ObservableObject { } public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus { - guard let result = result else { + guard let result else { return .none } - guard let debridMatch = realDebridHashes.first(where: { result.magnetHash == $0.hash }) else { + guard let debridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else { return .none } @@ -86,7 +103,7 @@ public class DebridManager: ObservableObject { return false } - if let realDebridItem = realDebridHashes.first(where: { magnetHash == $0.hash }) { + if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) { selectedRealDebridItem = realDebridItem return true } else { @@ -126,7 +143,7 @@ public class DebridManager: ObservableObject { } } - public func fetchRdDownload(searchResult: SearchResult, iaFile: RealDebridIAFile? = nil) async { + public func fetchRdDownload(searchResult: SearchResult) async { defer { currentDebridTask = nil showLoadingProgress = false @@ -141,14 +158,10 @@ public class DebridManager: ObservableObject { return } - var realDebridId: String? - do { - realDebridId = try await realDebrid.addMagnet(magnetLink: magnetLink) - var fileIds: [Int] = [] - if let iaFile = iaFile { + if let iaFile = selectedRealDebridFile { guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else { return } @@ -156,29 +169,52 @@ public class DebridManager: ObservableObject { fileIds = iaBatchFromFile.files.map(\.id) } - if let realDebridId = realDebridId { - try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds) + // If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link + let existingTorrents = try await realDebrid.userTorrents().filter { $0.hash == selectedRealDebridItem?.hash } - let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile?.batchFileIndex ?? 0) - let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) + // If the links match from a user's downloads, no need to re-run a download + if let existingTorrent = existingTorrents[safe: 0], + let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0] + { + let existingLinks = try await realDebrid.userDownloads().filter { $0.link == torrentLink } + if let existingLink = existingLinks[safe: 0]?.download { + realDebridDownloadUrl = existingLink + } else { + let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) + + realDebridDownloadUrl = downloadLink + } - realDebridDownloadUrl = downloadLink } else { - toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") + // Add a magnet after all the cache checks fail + selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink) + + if let realDebridId = selectedRealDebridID { + try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds) + + let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0) + let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) + + realDebridDownloadUrl = downloadLink + } else { + toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") + } } } catch { - let error = error as NSError - - switch error.code { - case -999: - toastModel?.updateToastDescription("Download cancelled", newToastType: .info) + switch error { + case RealDebridError.EmptyTorrents: + showDeleteAlert.toggle() default: - toastModel?.updateToastDescription("RealDebrid download error: \(error)") - } + let error = error as NSError - // Delete the torrent download if it exists - if let realDebridId = realDebridId { - try? await realDebrid.deleteTorrent(debridID: realDebridId) + switch error.code { + case -999: + toastModel?.updateToastDescription("Download cancelled", newToastType: .info) + default: + toastModel?.updateToastDescription("RealDebrid download error: \(error)") + } + + await deleteRdTorrent() } showLoadingProgress = false @@ -186,4 +222,12 @@ public class DebridManager: ObservableObject { print("RealDebrid download error: \(error)") } } + + public func deleteRdTorrent() async { + if let realDebridId = selectedRealDebridID { + try? await realDebrid.deleteTorrent(debridID: realDebridId) + } + + selectedRealDebridID = nil + } } diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index a55ad27..1767d93 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -11,6 +11,7 @@ enum ViewTab { case search case sources case settings + case library } @MainActor @@ -31,10 +32,16 @@ class NavigationViewModel: ObservableObject { @Published var isEditingSearch: Bool = false @Published var isSearching: Bool = false + @Published var selectedSearchResult: SearchResult? + + // For giving information in magnet choice sheet + @Published var selectedTitle: String? + @Published var selectedBatchTitle: String? + @Published var hideNavigationBar = false @Published var currentChoiceSheet: ChoiceSheetType? - @Published var activityItems: [Any] = [] + var activityItems: [Any] = [] // Used to show the activity sheet in the share menu @Published var showLocalActivitySheet = false @@ -44,15 +51,15 @@ class NavigationViewModel: ObservableObject { // Used between SourceListView and SourceSettingsView @Published var showSourceSettings: Bool = false - @Published var selectedSource: Source? + var selectedSource: Source? @Published var showSourceListEditor: Bool = false - @Published var selectedSourceList: SourceList? + var selectedSourceList: SourceList? @AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none @AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none - public func runDebridAction(action: DefaultDebridActionType?, urlString: String) { + public func runDebridAction(urlString: String, _ action: DefaultDebridActionType? = nil) { let selectedAction = action ?? defaultDebridAction switch selectedAction { @@ -86,11 +93,11 @@ class NavigationViewModel: ObservableObject { } } - public func runMagnetAction(action: DefaultMagnetActionType?, searchResult: SearchResult) { + public func runMagnetAction(magnetString: String?, _ action: DefaultMagnetActionType? = nil) { let selectedAction = action ?? defaultMagnetAction - guard let magnetLink = searchResult.magnetLink else { - toastModel?.toastDescription = "Could not run your action because the magnet link is invalid." + guard let magnetLink = magnetString else { + toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") print("Magnet action error: The magnet link is invalid.") return @@ -116,4 +123,22 @@ class NavigationViewModel: ObservableObject { } } } + + public func addToHistory(name: String?, source: String?, url: String?, subName: String? = nil) { + let backgroundContext = PersistenceController.shared.backgroundContext + + // The timeStamp and date are nil because the create function will make them automatically + PersistenceController.shared.createHistory( + entryJson: HistoryEntryJson( + name: name ?? "", + subName: subName, + url: url ?? "", + timeStamp: nil, + source: source + ), + date: nil + ) + + PersistenceController.shared.save(backgroundContext) + } } diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index eb7adc4..5554516 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -11,16 +11,6 @@ import SwiftSoup import SwiftUI import SwiftyJSON -public struct SearchResult: Hashable, Codable { - let title: String? - let source: String - let size: String? - let magnetLink: String? - let magnetHash: String? - let seeders: String? - let leechers: String? -} - class ScrapingViewModel: ObservableObject { @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false @@ -28,10 +18,9 @@ class ScrapingViewModel: ObservableObject { var toastModel: ToastViewModel? let byteCountFormatter: ByteCountFormatter = .init() - @Published var runningSearchTask: Task? + var runningSearchTask: Task? @Published var searchResults: [SearchResult] = [] @Published var searchText: String = "" - @Published var selectedSearchResult: SearchResult? @Published var filteredSource: Source? @Published var currentSourceName: String? @@ -40,6 +29,17 @@ class ScrapingViewModel: ObservableObject { searchResults = newResults } + // Utility function to print source specific errors + func sendSourceError(_ description: String, newToastType: ToastViewModel.ToastType? = nil) async { + let newDescription = "\(currentSourceName ?? "No source given"): \(description)" + await toastModel?.updateToastDescription( + newDescription, + newToastType: newToastType + ) + + print(newDescription) + } + public func scanSources(sources: [Source]) async { if sources.isEmpty { await toastModel?.updateToastDescription("There are no sources to search!", newToastType: .info) @@ -67,8 +67,7 @@ class ScrapingViewModel: ObservableObject { let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - await toastModel?.updateToastDescription("Could not process search query, invalid characters present.") - print("Could not process search query, invalid characters present") + await sendSourceError("Could not process search query, invalid characters present.") continue } @@ -85,7 +84,7 @@ class ScrapingViewModel: ObservableObject { fallbackUrls: source.fallbackUrls ) - if let data = data, + if let data, let html = String(data: data, encoding: .utf8) { let sourceResults = await scrapeHtml(source: source, baseUrl: baseUrl, html: html) @@ -110,7 +109,7 @@ class ScrapingViewModel: ObservableObject { ) } - if let data = data, + if let data, let rss = String(data: data, encoding: .utf8) { let sourceResults = await scrapeRss(source: source, rss: rss) @@ -156,7 +155,7 @@ class ScrapingViewModel: ObservableObject { fallbackUrls: source.fallbackUrls ) - if let data = data { + if let data { let sourceResults = await scrapeJson(source: source, jsonData: data) tempResults += sourceResults } @@ -181,7 +180,7 @@ class ScrapingViewModel: ObservableObject { return data } - if let fallbackUrls = fallbackUrls { + if let fallbackUrls { for fallbackUrl in fallbackUrls { if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl) { return data @@ -266,13 +265,11 @@ class ScrapingViewModel: ObservableObject { case -999: await toastModel?.updateToastDescription("Search cancelled", newToastType: .info) case -1001: - await toastModel?.updateToastDescription("Credentials request timed out") + await sendSourceError("Credentials request timed out") default: - await toastModel?.updateToastDescription("Error in fetching an API credential \(error)") + await sendSourceError("Error in fetching an API credential \(error)") } - print("Error in fetching an API credential \(error)") - return nil } } @@ -280,9 +277,7 @@ class ScrapingViewModel: ObservableObject { // Fetches the data for a URL public func fetchWebsiteData(urlString: String) async -> Data? { guard let url = URL(string: urlString) else { - await toastModel?.updateToastDescription("Source doesn't contain a valid URL, contact the source dev!") - - print("Source doesn't contain a valid URL, contact the source dev!") + await sendSourceError("Source doesn't contain a valid URL, contact the source dev!") return nil } @@ -299,13 +294,11 @@ class ScrapingViewModel: ObservableObject { case -999: await toastModel?.updateToastDescription("Search cancelled", newToastType: .info) case -1001: - await toastModel?.updateToastDescription("Data request timed out. Trying fallback URLs if present.") + await sendSourceError("Data request timed out. Trying fallback URLs if present.") default: - await toastModel?.updateToastDescription("Error in fetching website data \(error)") + await sendSourceError("Error in fetching website data \(error)") } - print("Error in fetching data \(error)") - return nil } } @@ -370,7 +363,7 @@ class ScrapingViewModel: ObservableObject { } } } else if - let searchResult = searchResult, + let searchResult, let magnetLink = searchResult.magnetLink, magnetLink.starts(with: "magnet:"), !tempResults.contains(searchResult) @@ -415,7 +408,7 @@ class ScrapingViewModel: ObservableObject { if let magnetLinkParser = jsonParser.magnetLink, existingSearchResult?.magnetLink == nil { let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue link = rawLink is NSNull ? nil : String(describing: rawLink) - } else if let magnetHash = magnetHash { + } else if let magnetHash { link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers) } @@ -476,8 +469,7 @@ class ScrapingViewModel: ObservableObject { let document = try SwiftSoup.parse(rss, "", Parser.xmlParser()) items = try document.getElementsByTag(rssParser.items) } catch { - await toastModel?.updateToastDescription("RSS scraping error, couldn't fetch items: \(error)") - print("RSS scraping error, couldn't fetch items: \(error)") + await sendSourceError("RSS scraping error, couldn't fetch items: \(error)") return tempResults } @@ -517,7 +509,7 @@ class ScrapingViewModel: ObservableObject { discriminator: magnetLinkParser.discriminator, regexString: magnetLinkParser.regex ) - } else if let magnetHash = magnetHash { + } else if let magnetHash { link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers) } else { continue @@ -597,7 +589,7 @@ class ScrapingViewModel: ObservableObject { parsedValue = try item.getElementsByTag(query).first()?.text() default: // If there's a key/value to lookup the attribute with, query it. Othewise assume the value is in the same attribute - if let discriminator = discriminator { + if let discriminator { let containerElement = try item.getElementsByAttributeValue(discriminator, query).first() parsedValue = try containerElement?.attr(attribute) } else { @@ -607,8 +599,8 @@ class ScrapingViewModel: ObservableObject { } // A capture group must be used in the provided regex - if let regexString = regexString, - let parsedValue = parsedValue, + if let regexString, + let parsedValue, let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value { return regexValue @@ -631,8 +623,7 @@ class ScrapingViewModel: ObservableObject { let document = try SwiftSoup.parse(html) rows = try document.select(htmlParser.rows) } catch { - await toastModel?.updateToastDescription("Scraping error, couldn't fetch rows: \(error)") - print("Scraping error, couldn't fetch rows: \(error)") + await sendSourceError("Scraping error, couldn't fetch rows: \(error)") return tempResults } @@ -764,8 +755,7 @@ class ScrapingViewModel: ObservableObject { tempResults.append(result) } } catch { - await toastModel?.updateToastDescription("Scraping error: \(error)") - print("Scraping error: \(error)") + await sendSourceError("Scraping error: \(error)") continue } @@ -788,8 +778,8 @@ class ScrapingViewModel: ObservableObject { } // A capture group must be used in the provided regex - if let regexString = regexString, - let parsedValue = parsedValue, + if let regexString, + let parsedValue, let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value { return regexValue @@ -802,10 +792,10 @@ class ScrapingViewModel: ObservableObject { public func fetchMagnetHash(magnetLink: String? = nil, existingHash: String? = nil) -> String? { var magnetHash: String - if let existingHash = existingHash { + if let existingHash { magnetHash = existingHash } else if - let magnetLink = magnetLink, + let magnetLink, let firstSplit = magnetLink.split(separator: ":")[safe: 3], let tempHash = firstSplit.split(separator: "&")[safe: 0] { @@ -850,11 +840,11 @@ class ScrapingViewModel: ObservableObject { magnetLinkArray.append(magnetHash) - if let title = title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { magnetLinkArray.append("&dn=\(encodedTitle)") } - if let trackers = trackers { + if let trackers { for trackerUrl in trackers { if URL(string: trackerUrl) != nil, let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) @@ -912,7 +902,7 @@ class ScrapingViewModel: ObservableObject { } } - await toastModel?.updateToastDescription(responseArray.joined()) + await sendSourceError(responseArray.joined(separator: " ")) PersistenceController.shared.save(backgroundContext) } diff --git a/Ferrite/ViewModels/SourceManager.swift b/Ferrite/ViewModels/SourceManager.swift index 072a829..de8989f 100644 --- a/Ferrite/ViewModels/SourceManager.swift +++ b/Ferrite/ViewModels/SourceManager.swift @@ -1,5 +1,5 @@ // -// SourceViewModel.swift +// SourceManager.swift // Ferrite // // Created by Brian Dashore on 7/25/22. @@ -7,14 +7,14 @@ import CoreData import Foundation -import UIKit +import SwiftUI public class SourceManager: ObservableObject { var toastModel: ToastViewModel? @Published var availableSources: [SourceJson] = [] - @Published var urlErrorAlertText = "" + var urlErrorAlertText = "" @Published var showUrlErrorAlert = false @MainActor @@ -52,14 +52,30 @@ public class SourceManager: ObservableObject { } } + func fetchUpdatedSources(installedSources: FetchedResults) -> [SourceJson] { + var updatedSources: [SourceJson] = [] + + for source in installedSources { + if let availableSource = availableSources.first(where: { + source.listId == $0.listId && source.name == $0.name && source.author == $0.author + }), + availableSource.version > source.version + { + updatedSources.append(availableSource) + } + } + + return updatedSources + } + // Checks if the current app version is supported by the source func checkAppVersion(minVersion: String?) -> Bool { // If there's no min version, assume that every version is supported - guard let minVersion = minVersion else { + guard let minVersion else { return true } - return UIApplication.shared.appVersion >= minVersion + return Application.shared.appVersion >= minVersion } // Fetches sources using the background context @@ -369,7 +385,7 @@ public class SourceManager: ObservableObject { let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!)) let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data) - if let existingSourceList = existingSourceList { + if let existingSourceList { existingSourceList.urlString = sourceUrl existingSourceList.name = rawResponse.name existingSourceList.author = rawResponse.author diff --git a/Ferrite/ViewModels/ToastViewModel.swift b/Ferrite/ViewModels/ToastViewModel.swift index e35d052..d3f1704 100644 --- a/Ferrite/ViewModels/ToastViewModel.swift +++ b/Ferrite/ViewModels/ToastViewModel.swift @@ -36,7 +36,7 @@ class ToastViewModel: ObservableObject { @Published var showToast: Bool = false public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) { - if let newToastType = newToastType { + if let newToastType { toastType = newToastType } diff --git a/Ferrite/Views/AboutView.swift b/Ferrite/Views/AboutView.swift index b2285aa..1b1bed5 100644 --- a/Ferrite/Views/AboutView.swift +++ b/Ferrite/Views/AboutView.swift @@ -9,24 +9,32 @@ import SwiftUI struct AboutView: View { var body: some View { - VStack { - Image("AppImage") - .resizable() - .frame(width: 100, height: 100) - .cornerRadius(25) - - Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.") - .padding() - - List { - ListRowTextView(leftText: "Version", rightText: UIApplication.shared.appVersion) - ListRowTextView(leftText: "Build number", rightText: UIApplication.shared.appBuild) - ListRowTextView(leftText: "Build type", rightText: UIApplication.shared.buildType) + List { + Section { + ListRowTextView(leftText: "Version", rightText: Application.shared.appVersion) + ListRowTextView(leftText: "Build number", rightText: Application.shared.appBuild) + ListRowTextView(leftText: "Build type", rightText: Application.shared.buildType) ListRowLinkView(text: "Discord server", link: "https://discord.gg/sYQxnuD7Fj") ListRowLinkView(text: "GitHub repository", link: "https://github.com/bdashore3/Ferrite") + } header: { + VStack(alignment: .center) { + Image("AppImage") + .resizable() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 100 * 0.225, style: .continuous)) + .padding(.top, 24) + + Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.") + .textCase(.none) + .foregroundColor(.label) + .font(.body) + .padding(.top, 8) + .padding(.bottom, 20) + } + .listRowInsets(EdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 0)) } - .listStyle(.insetGrouped) } + .listStyle(.insetGrouped) .navigationTitle("About") } } diff --git a/Ferrite/Views/BatchChoiceView.swift b/Ferrite/Views/BatchChoiceView.swift index 54b6237..0a70ac0 100644 --- a/Ferrite/Views/BatchChoiceView.swift +++ b/Ferrite/Views/BatchChoiceView.swift @@ -8,12 +8,12 @@ import SwiftUI struct BatchChoiceView: View { - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var navModel: NavigationViewModel + let backgroundContext = PersistenceController.shared.backgroundContext + var body: some View { NavView { List { @@ -21,14 +21,16 @@ struct BatchChoiceView: View { Button(file.name) { debridManager.selectedRealDebridFile = file - if let searchResult = scrapingModel.selectedSearchResult { + if let searchResult = navModel.selectedSearchResult { debridManager.currentDebridTask = Task { - await debridManager.fetchRdDownload(searchResult: searchResult, iaFile: file) + await debridManager.fetchRdDownload(searchResult: searchResult) if !debridManager.realDebridDownloadUrl.isEmpty { // The download may complete before this sheet dismisses try? await Task.sleep(seconds: 1) - navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl) + navModel.selectedBatchTitle = file.name + navModel.addToHistory(name: searchResult.title, source: searchResult.source, url: debridManager.realDebridDownloadUrl, subName: file.name) + navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl) } debridManager.selectedRealDebridFile = nil @@ -36,8 +38,9 @@ struct BatchChoiceView: View { } } - presentationMode.wrappedValue.dismiss() + navModel.currentChoiceSheet = nil } + .backport.tint(.primary) } } .listStyle(.insetGrouped) @@ -46,9 +49,12 @@ struct BatchChoiceView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { - debridManager.selectedRealDebridItem = nil + navModel.currentChoiceSheet = nil - presentationMode.wrappedValue.dismiss() + Task { + try? await Task.sleep(seconds: 1) + debridManager.selectedRealDebridItem = nil + } } } } diff --git a/Ferrite/Views/CommonViews/AlertButton.swift b/Ferrite/Views/CommonViews/AlertButton.swift new file mode 100644 index 0000000..664317a --- /dev/null +++ b/Ferrite/Views/CommonViews/AlertButton.swift @@ -0,0 +1,71 @@ +// +// AlertButton.swift +// Ferrite +// +// Created by Brian Dashore on 9/8/22. +// +// Universal alert button for dynamic alert views +// + +import SwiftUI + +struct AlertButton: Identifiable { + enum Role { + case destructive + case cancel + } + + let id: UUID + let label: String + let action: () -> Void + let role: Role? + + // Used for all buttons + init(_ label: String, role: Role? = nil, action: @escaping () -> Void) { + id = UUID() + self.label = label + self.action = action + self.role = role + } + + // Used for buttons with no action + init(_ label: String = "Cancel", role: Role? = nil) { + id = UUID() + self.label = label + action = {} + self.role = role + } + + func toActionButton() -> Alert.Button { + if let role { + switch role { + case .cancel: + return .cancel(Text(label)) + case .destructive: + return .destructive(Text(label), action: action) + } + } else { + return .default(Text(label), action: action) + } + } + + @available(iOS 15.0, *) + @ViewBuilder + func toButtonView() -> some View { + Button(label, role: toButtonRole(role), action: action) + } + + @available(iOS 15.0, *) + func toButtonRole(_ role: Role?) -> ButtonRole? { + if let role { + switch role { + case .destructive: + return .destructive + case .cancel: + return .cancel + } + } else { + return nil + } + } +} diff --git a/Ferrite/Views/CommonViews/Backport.swift b/Ferrite/Views/CommonViews/Backport.swift new file mode 100644 index 0000000..d57643a --- /dev/null +++ b/Ferrite/Views/CommonViews/Backport.swift @@ -0,0 +1,103 @@ +// +// Backport.swift +// Ferrite +// +// Created by Brian Dashore on 9/29/22. +// + +import SwiftUI + +public struct Backport { + public let content: Content + + public init(_ content: Content) { + self.content = content + } +} + +extension View { + var backport: Backport { Backport(self) } +} + +extension Backport where Content: View { + @ViewBuilder func alert(isPresented: Binding, title: String, message: String?, buttons: [AlertButton]) -> some View { + if #available(iOS 15, *) { + content + .alert( + title, + isPresented: isPresented, + actions: { + ForEach(buttons) { button in + button.toButtonView() + } + }, + message: { + if let message { + Text(message) + } + } + ) + } else { + content + .background { + Color.clear + .alert(isPresented: isPresented) { + if let primaryButton = buttons[safe: 0], + let secondaryButton = buttons[safe: 1] + { + return Alert( + title: Text(title), + message: message.map { Text($0) } ?? nil, + primaryButton: primaryButton.toActionButton(), + secondaryButton: secondaryButton.toActionButton() + ) + } else { + return Alert( + title: Text(title), + message: message.map { Text($0) } ?? nil, + dismissButton: buttons[0].toActionButton() + ) + } + } + } + } + } + + @ViewBuilder func confirmationDialog(isPresented: Binding, title: String, message: String?, buttons: [AlertButton]) -> some View { + if #available(iOS 15, *) { + content + .confirmationDialog( + title, + isPresented: isPresented, + titleVisibility: .visible + ) { + ForEach(buttons) { button in + button.toButtonView() + } + } message: { + if let message { + Text(message) + } + } + } else { + content + .actionSheet(isPresented: isPresented) { + ActionSheet( + title: Text(title), + message: message.map { Text($0) } ?? nil, + buttons: [buttons.map { $0.toActionButton() }, [.cancel()]].flatMap { $0 } + ) + } + } + } + + @ViewBuilder func tint(_ color: Color) -> some View { + if #available(iOS 15, *) { + content + .tint(color) + } else { + content + .accentColor(color) + } + } +} diff --git a/Ferrite/Views/CommonViews/DynamicAccentColor.swift b/Ferrite/Views/CommonViews/DynamicAccentColor.swift deleted file mode 100644 index 02a2e79..0000000 --- a/Ferrite/Views/CommonViews/DynamicAccentColor.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// dynamicAccentColor.swift -// Ferrite -// -// Created by Brian Dashore on 8/15/22. -// - -import SwiftUI - -struct DynamicAccentColor: ViewModifier { - let color: Color - - func body(content: Content) -> some View { - if #available(iOS 15, *) { - content - .tint(color) - } else { - content - .accentColor(color) - } - } -} diff --git a/Ferrite/Views/CommonViews/DynamicFetchRequest.swift b/Ferrite/Views/CommonViews/DynamicFetchRequest.swift new file mode 100644 index 0000000..145addb --- /dev/null +++ b/Ferrite/Views/CommonViews/DynamicFetchRequest.swift @@ -0,0 +1,29 @@ +// +// DynamicFetchRequest.swift +// Ferrite +// +// Created by Brian Dashore on 9/6/22. +// +// Used for FetchRequests with a dynamic predicate +// iOS 14 compatible view +// + +import CoreData +import SwiftUI + +struct DynamicFetchRequest: View { + @FetchRequest var fetchRequest: FetchedResults + + let content: (FetchedResults) -> Content + + var body: some View { + content(fetchRequest) + } + + init(predicate: NSPredicate?, + @ViewBuilder content: @escaping (FetchedResults) -> Content) + { + _fetchRequest = FetchRequest(sortDescriptors: [], predicate: predicate) + self.content = content + } +} diff --git a/Ferrite/Views/CommonViews/EmptyInstructionView.swift b/Ferrite/Views/CommonViews/EmptyInstructionView.swift new file mode 100644 index 0000000..8cd0055 --- /dev/null +++ b/Ferrite/Views/CommonViews/EmptyInstructionView.swift @@ -0,0 +1,27 @@ +// +// EmptyInstructionView.swift +// Ferrite +// +// Created by Brian Dashore on 9/5/22. +// + +import SwiftUI + +struct EmptyInstructionView: View { + let title: String + let message: String + + var body: some View { + VStack(spacing: 5) { + Text(title) + .font(.system(size: 25, weight: .semibold)) + + Text(message) + .padding(.horizontal, 50) + } + .multilineTextAlignment(.center) + .foregroundColor(.secondaryLabel) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + } +} diff --git a/Ferrite/Views/CommonViews/GroupBoxStyle.swift b/Ferrite/Views/CommonViews/GroupBoxStyle.swift deleted file mode 100644 index 6398a3b..0000000 --- a/Ferrite/Views/CommonViews/GroupBoxStyle.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// GroupBoxStyle.swift -// Ferrite -// -// Created by Brian Dashore on 7/21/22. -// - -import SwiftUI - -struct ErrorGroupBoxStyle: GroupBoxStyle { - func makeBody(configuration: Configuration) -> some View { - VStack { - configuration.label - configuration.content - } - .padding(10) - .background(Color(UIColor.secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } -} diff --git a/Ferrite/Views/CommonViews/InlineHeader.swift b/Ferrite/Views/CommonViews/InlineHeader.swift new file mode 100644 index 0000000..faff7b7 --- /dev/null +++ b/Ferrite/Views/CommonViews/InlineHeader.swift @@ -0,0 +1,29 @@ +// +// InlineHeader.swift +// Ferrite +// +// Created by Brian Dashore on 9/5/22. +// +// For iOS 15's weird defaults regarding sectioned list padding +// + +import SwiftUI + +struct InlineHeader: View { + let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + if #available(iOS 16, *) { + Text(title) + } else if #available(iOS 15, *) { + Text(title) + .listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 0)) + } else { + Text(title) + } + } +} diff --git a/Ferrite/Views/CommonViews/ListRowViews.swift b/Ferrite/Views/CommonViews/ListRowViews.swift index 770c4be..60cf80a 100644 --- a/Ferrite/Views/CommonViews/ListRowViews.swift +++ b/Ferrite/Views/CommonViews/ListRowViews.swift @@ -4,11 +4,11 @@ // // Created by Brian Dashore on 7/26/22. // +// List row button, text, and link boilerplate +// import SwiftUI -// These views were imported from Asobi -// View alias for a list row with an external link struct ListRowLinkView: View { let text: String let link: String @@ -64,7 +64,7 @@ struct ListRowTextView: View { Spacer() - if let rightText = rightText { + if let rightText { Text(rightText) } else { Image(systemName: rightSymbol!) diff --git a/Ferrite/Views/CommonViews/Modifiers/ConditionalContextMenu.swift b/Ferrite/Views/CommonViews/Modifiers/ConditionalContextMenu.swift new file mode 100644 index 0000000..d981673 --- /dev/null +++ b/Ferrite/Views/CommonViews/Modifiers/ConditionalContextMenu.swift @@ -0,0 +1,39 @@ +// +// ConditionalContextMenu.swift +// Ferrite +// +// Created by Brian Dashore on 9/3/22. +// +// Used as a workaround for iOS 15 not updating context views with conditional variables +// A stateful ID is required for the contextMenu to update itself. +// + +import SwiftUI + +struct ConditionalContextMenu: ViewModifier { + let internalContent: () -> InternalContent + let id: ID + + init(@ViewBuilder _ internalContent: @escaping () -> InternalContent, id: ID) { + self.internalContent = internalContent + self.id = id + } + + func body(content: Content) -> some View { + if #available(iOS 16, *) { + content + .contextMenu { + internalContent() + } + } else { + content + .background { + Color.clear + .contextMenu { + internalContent() + } + .id(id) + } + } + } +} diff --git a/Ferrite/Views/CommonViews/Modifiers/ConditionalId.swift b/Ferrite/Views/CommonViews/Modifiers/ConditionalId.swift new file mode 100644 index 0000000..146f87f --- /dev/null +++ b/Ferrite/Views/CommonViews/Modifiers/ConditionalId.swift @@ -0,0 +1,24 @@ +// +// ConditionalId.swift +// Ferrite +// +// Created by Brian Dashore on 9/4/22. +// +// Applies an ID below iOS 16 +// This is due to ID workarounds making iOS 16 apps crash +// + +import SwiftUI + +struct ConditionalId: ViewModifier { + let id: ID + + func body(content: Content) -> some View { + if #available(iOS 16, *) { + content + } else { + content + .id(id) + } + } +} diff --git a/Ferrite/Views/CommonViews/Modifiers/DisableInteraction.swift b/Ferrite/Views/CommonViews/Modifiers/DisableInteraction.swift new file mode 100644 index 0000000..97d428c --- /dev/null +++ b/Ferrite/Views/CommonViews/Modifiers/DisableInteraction.swift @@ -0,0 +1,25 @@ +// +// DisableInteraction.swift +// Ferrite +// +// Created by Brian Dashore on 9/13/22. +// +// Disables interaction on any view without applying the appearance +// + +import SwiftUI + +struct DisableInteraction: ViewModifier { + let disabled: Bool + + func body(content: Content) -> some View { + content + .overlay { + if disabled { + Color.clear + .contentShape(Rectangle()) + .gesture(TapGesture()) + } + } + } +} diff --git a/Ferrite/Views/CommonViews/Modifiers/DisabledAppearance.swift b/Ferrite/Views/CommonViews/Modifiers/DisabledAppearance.swift new file mode 100644 index 0000000..1caeaf6 --- /dev/null +++ b/Ferrite/Views/CommonViews/Modifiers/DisabledAppearance.swift @@ -0,0 +1,23 @@ +// +// DisabledAppearance.swift +// Ferrite +// +// Created by Brian Dashore on 9/10/22. +// +// Adds opacity transitions to the disabled modifier +// + +import SwiftUI + +struct DisabledAppearance: ViewModifier { + let disabled: Bool + let dimmedOpacity: Double? + let animation: Animation? + + func body(content: Content) -> some View { + content + .disabled(disabled) + .opacity(disabled ? dimmedOpacity.map { $0 } ?? 0.5 : 1) + .animation(animation.map { $0 } ?? .none, value: disabled) + } +} diff --git a/Ferrite/Views/CommonViews/Modifiers/InlinedList.swift b/Ferrite/Views/CommonViews/Modifiers/InlinedList.swift new file mode 100644 index 0000000..3e2cac2 --- /dev/null +++ b/Ferrite/Views/CommonViews/Modifiers/InlinedList.swift @@ -0,0 +1,28 @@ +// +// InlinedList.swift +// Ferrite +// +// Created by Brian Dashore on 9/4/22. +// +// Removes the top padding on unsectioned lists +// If a list is sectioned, see InlineHeader +// + +import Introspect +import SwiftUI + +struct InlinedList: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16, *) { + content + .introspectCollectionView { collectionView in + collectionView.contentInset.top = -20 + } + } else { + content + .introspectTableView { tableView in + tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 20)) + } + } + } +} diff --git a/Ferrite/Views/CommonViews/NavView.swift b/Ferrite/Views/CommonViews/NavView.swift index cd8ffd4..b89b95f 100644 --- a/Ferrite/Views/CommonViews/NavView.swift +++ b/Ferrite/Views/CommonViews/NavView.swift @@ -3,6 +3,9 @@ // Ferrite // // Created by Brian Dashore on 7/4/22. +// Contributed by Mantton +// +// A wrapper that switches between NavigationStack and the legacy NavigationView // import SwiftUI diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 6f92f4f..9a117fa 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -30,7 +30,7 @@ struct ContentView: View { var body: some View { NavView { VStack(spacing: 10) { - HStack { + HStack(spacing: 6) { Text("Filter") .foregroundColor(.secondary) @@ -50,10 +50,10 @@ struct ContentView: View { Button { selectedSource = source } label: { - Text(name) - if selectedSource == source { - Image(systemName: "checkmark") + Label(name, systemImage: "checkmark") + } else { + Text(name) } } } @@ -72,30 +72,6 @@ struct ContentView: View { SearchResultsView() } - .sheet(item: $navModel.currentChoiceSheet) { item in - Group { - switch item { - case .magnet: - MagnetChoiceView() - .environmentObject(debridManager) - .environmentObject(scrapingModel) - .environmentObject(navModel) - case .batch: - BatchChoiceView() - .environmentObject(debridManager) - .environmentObject(scrapingModel) - .environmentObject(navModel) - case .activity: - if #available(iOS 16, *) { - AppActivityView(activityItems: navModel.activityItems) - .presentationDetents([.medium, .large]) - } else { - AppActivityView(activityItems: navModel.activityItems) - } - } - } - .dynamicAccentColor(.primary) - } .navigationTitle("Search") .navigationSearchBar { SearchBar("Search", @@ -111,7 +87,11 @@ struct ContentView: View { await scrapingModel.scanSources(sources: sources) if realDebridEnabled, !scrapingModel.searchResults.isEmpty { - await debridManager.populateDebridHashes(scrapingModel.searchResults) + debridManager.realDebridIAValues = [] + + await debridManager.populateDebridHashes( + scrapingModel.searchResults.compactMap(\.magnetHash) + ) } navModel.showSearchProgress = false diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift new file mode 100644 index 0000000..39c475f --- /dev/null +++ b/Ferrite/Views/LibraryView.swift @@ -0,0 +1,95 @@ +// +// LibraryView.swift +// Ferrite +// +// Created by Brian Dashore on 9/2/22. +// + +import SwiftUI + +struct LibraryView: View { + enum LibraryPickerSegment { + case bookmarks + case history + } + + @EnvironmentObject var navModel: NavigationViewModel + + @FetchRequest( + entity: Bookmark.entity(), + sortDescriptors: [ + NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true) + ] + ) var bookmarks: FetchedResults + + @FetchRequest( + entity: History.entity(), + sortDescriptors: [ + NSSortDescriptor(keyPath: \History.date, ascending: false) + ] + ) var history: FetchedResults + + @State private var historyEmpty = true + + @State private var selectedSegment: LibraryPickerSegment = .bookmarks + @State private var editMode: EditMode = .inactive + + var body: some View { + NavView { + VStack { + Picker("Segments", selection: $selectedSegment) { + Text("Bookmarks").tag(LibraryPickerSegment.bookmarks) + Text("History").tag(LibraryPickerSegment.history) + } + .pickerStyle(.segmented) + .padding() + + switch selectedSegment { + case .bookmarks: + BookmarksView(bookmarks: bookmarks) + case .history: + HistoryView(history: history) + } + + Spacer() + } + .overlay { + switch selectedSegment { + case .bookmarks: + if bookmarks.isEmpty { + EmptyInstructionView(title: "No Bookmarks", message: "Add a bookmark from search results") + } + case .history: + if history.isEmpty { + EmptyInstructionView(title: "No History", message: "Start watching to build history") + } + } + } + .navigationTitle("Library") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack { + EditButton() + + if selectedSegment == .history { + HistoryActionsView() + } + } + } + } + .environment(\.editMode, $editMode) + } + .onChange(of: selectedSegment) { _ in + editMode = .inactive + } + .onDisappear { + editMode = .inactive + } + } +} + +struct LibraryView_Previews: PreviewProvider { + static var previews: some View { + LibraryView() + } +} diff --git a/Ferrite/Views/LibraryViews/BookmarksView.swift b/Ferrite/Views/LibraryViews/BookmarksView.swift new file mode 100644 index 0000000..d668b62 --- /dev/null +++ b/Ferrite/Views/LibraryViews/BookmarksView.swift @@ -0,0 +1,68 @@ +// +// BookmarksView.swift +// Ferrite +// +// Created by Brian Dashore on 9/2/22. +// + +import SwiftUI + +struct BookmarksView: View { + @Environment(\.verticalSizeClass) var verticalSizeClass + + @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager + + @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false + + let backgroundContext = PersistenceController.shared.backgroundContext + + var bookmarks: FetchedResults + + @State private var viewTask: Task? + + var body: some View { + ZStack { + if !bookmarks.isEmpty { + List { + ForEach(bookmarks, id: \.self) { bookmark in + SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark) + } + .onDelete { offsets in + for index in offsets { + if let bookmark = bookmarks[safe: index] { + PersistenceController.shared.delete(bookmark, context: backgroundContext) + + NotificationCenter.default.post(name: .didDeleteBookmark, object: nil) + } + } + } + .onMove { source, destination in + var changedBookmarks = bookmarks.map { $0 } + + changedBookmarks.move(fromOffsets: source, toOffset: destination) + + for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) { + changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex) + } + + PersistenceController.shared.save() + } + } + .inlinedList() + .listStyle(.insetGrouped) + } + } + .onAppear { + if realDebridEnabled { + viewTask = Task { + let hashes = bookmarks.compactMap(\.magnetHash) + await debridManager.populateDebridHashes(hashes) + } + } + } + .onDisappear { + viewTask?.cancel() + } + } +} diff --git a/Ferrite/Views/LibraryViews/HistoryActionsView.swift b/Ferrite/Views/LibraryViews/HistoryActionsView.swift new file mode 100644 index 0000000..083b094 --- /dev/null +++ b/Ferrite/Views/LibraryViews/HistoryActionsView.swift @@ -0,0 +1,54 @@ +// +// HistoryActionsView.swift +// Ferrite +// +// Created by Brian Dashore on 9/7/22. +// + +import SwiftUI + +struct HistoryActionsView: View { + @EnvironmentObject var toastModel: ToastViewModel + + @State private var showActionSheet = false + + var body: some View { + Button("Clear") { + showActionSheet.toggle() + } + .backport.tint(.red) + .backport.confirmationDialog( + isPresented: $showActionSheet, + title: "Clear watch history", + message: "This is an irreversible action!", + buttons: [ + AlertButton("Past day", role: .destructive) { + deleteHistory(.day) + }, + AlertButton("Past week", role: .destructive) { + deleteHistory(.week) + }, + AlertButton("Past month", role: .destructive) { + deleteHistory(.month) + }, + AlertButton("All time", role: .destructive) { + deleteHistory(.allTime) + } + ] + ) + } + + func deleteHistory(_ deleteRange: HistoryDeleteRange) { + do { + try PersistenceController.shared.batchDeleteHistory(range: deleteRange) + } catch { + toastModel.updateToastDescription("History delete error: \(error)") + } + } +} + +struct HistoryActionsView_Previews: PreviewProvider { + static var previews: some View { + HistoryActionsView() + } +} diff --git a/Ferrite/Views/LibraryViews/HistoryButtonView.swift b/Ferrite/Views/LibraryViews/HistoryButtonView.swift new file mode 100644 index 0000000..9399b16 --- /dev/null +++ b/Ferrite/Views/LibraryViews/HistoryButtonView.swift @@ -0,0 +1,80 @@ +// +// HistoryButtonView.swift +// Ferrite +// +// Created by Brian Dashore on 9/9/22. +// + +import SwiftUI + +struct HistoryButtonView: View { + @EnvironmentObject var toastModel: ToastViewModel + @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager + + let entry: HistoryEntry + + var body: some View { + Button { + navModel.selectedTitle = entry.name + navModel.selectedBatchTitle = entry.subName + + if let url = entry.url { + if url.starts(with: "https://") { + Task { + debridManager.realDebridDownloadUrl = url + navModel.runDebridAction(urlString: url) + + if navModel.currentChoiceSheet != .magnet { + debridManager.realDebridDownloadUrl = "" + } + } + } else { + navModel.runMagnetAction(magnetString: url) + } + } else { + toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.") + } + } label: { + VStack(alignment: .leading) { + VStack(alignment: .leading) { + Text(entry.name ?? "Unknown title") + .font(entry.subName == nil ? .body : .subheadline) + .lineLimit(entry.subName == nil ? 2 : 1) + + if let subName = entry.subName { + Text(subName) + .foregroundColor(.gray) + .font(.subheadline) + .lineLimit(2) + } + } + + HStack { + Text(entry.source ?? "Unknown source") + + Spacer() + + Text("DEBRID") + .fontWeight(.bold) + .padding(3) + .background { + Group { + if let url = entry.url, url.starts(with: "https://") { + Color.green + } else { + Color.red + } + } + .cornerRadius(4) + .opacity(0.5) + } + } + .font(.caption) + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + } + .backport.tint(.primary) + .disableInteraction(navModel.currentChoiceSheet != nil) + } +} diff --git a/Ferrite/Views/LibraryViews/HistoryView.swift b/Ferrite/Views/LibraryViews/HistoryView.swift new file mode 100644 index 0000000..e53ffb5 --- /dev/null +++ b/Ferrite/Views/LibraryViews/HistoryView.swift @@ -0,0 +1,65 @@ +// +// HistoryView.swift +// Ferrite +// +// Created by Brian Dashore on 9/2/22. +// + +import SwiftUI + +struct HistoryView: View { + @EnvironmentObject var navModel: NavigationViewModel + + let backgroundContext = PersistenceController.shared.backgroundContext + + var history: FetchedResults + var formatter: DateFormatter = .init() + + @State private var historyIndex = 0 + + init(history: FetchedResults) { + self.history = history + + formatter.dateStyle = .medium + formatter.timeStyle = .none + } + + func groupedEntries(_ result: FetchedResults) -> [[History]] { + Dictionary(grouping: result) { (element: History) in + element.dateString ?? "" + }.values.sorted { $0[0].date ?? Date() > $1[0].date ?? Date() } + } + + var body: some View { + if !history.isEmpty { + List { + ForEach(groupedEntries(history), id: \.self) { (section: [History]) in + Section(header: Text(formatter.string(from: section[0].date ?? Date()))) { + ForEach(section, id: \.self) { history in + ForEach(history.entryArray) { entry in + HistoryButtonView(entry: entry) + } + .onDelete { offsets in + removeEntry(at: offsets, from: history) + } + } + } + } + } + .listStyle(.insetGrouped) + } + } + + func removeEntry(at offsets: IndexSet, from history: History) { + for index in offsets { + if let entry = history.entryArray[safe: index] { + history.removeFromEntries(entry) + PersistenceController.shared.delete(entry, context: backgroundContext) + } + + if history.entryArray.isEmpty { + PersistenceController.shared.delete(history, context: backgroundContext) + } + } + } +} diff --git a/Ferrite/Views/MagnetChoiceView.swift b/Ferrite/Views/MagnetChoiceView.swift index a9701df..1ad0c77 100644 --- a/Ferrite/Views/MagnetChoiceView.swift +++ b/Ferrite/Views/MagnetChoiceView.swift @@ -23,31 +23,44 @@ struct MagnetChoiceView: View { var body: some View { NavView { Form { - if realDebridEnabled, debridManager.matchSearchResult(result: scrapingModel.selectedSearchResult) != .none { + Section(header: "Now Playing") { + VStack(alignment: .leading, spacing: 5) { + Text(navModel.selectedTitle ?? "No title") + .font(.callout) + .lineLimit(navModel.selectedBatchTitle == nil ? .max : 1) + + if let batchTitle = navModel.selectedBatchTitle { + Text(batchTitle) + .foregroundColor(.gray) + .font(.subheadline) + } + } + } + + if !debridManager.realDebridDownloadUrl.isEmpty { Section(header: "Real Debrid options") { ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") { - navModel.runDebridAction(action: .outplayer, urlString: debridManager.realDebridDownloadUrl) + navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .outplayer) } ListRowButtonView("Play on VLC", systemImage: "arrow.up.forward.app.fill") { - navModel.runDebridAction(action: .vlc, urlString: debridManager.realDebridDownloadUrl) + navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .vlc) } ListRowButtonView("Play on Infuse", systemImage: "arrow.up.forward.app.fill") { - navModel.runDebridAction(action: .infuse, urlString: debridManager.realDebridDownloadUrl) + navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .infuse) } ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") { UIPasteboard.general.string = debridManager.realDebridDownloadUrl showLinkCopyAlert.toggle() } - .alert(isPresented: $showLinkCopyAlert) { - Alert( - title: Text("Copied"), - message: Text("Download link copied successfully"), - dismissButton: .cancel(Text("OK")) - ) - } + .backport.alert( + isPresented: $showLinkCopyAlert, + title: "Copied", + message: "Download link copied successfully", + buttons: [AlertButton("OK")] + ) ListRowButtonView("Share download URL", systemImage: "square.and.arrow.up.fill") { if let url = URL(string: debridManager.realDebridDownloadUrl) { @@ -60,19 +73,18 @@ struct MagnetChoiceView: View { Section(header: "Magnet options") { ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") { - UIPasteboard.general.string = scrapingModel.selectedSearchResult?.magnetLink + UIPasteboard.general.string = navModel.selectedSearchResult?.magnetLink showMagnetCopyAlert.toggle() } - .alert(isPresented: $showMagnetCopyAlert) { - Alert( - title: Text("Copied"), - message: Text("Magnet link copied successfully"), - dismissButton: .cancel(Text("OK")) - ) - } + .backport.alert( + isPresented: $showMagnetCopyAlert, + title: "Copied", + message: "Magnet link copied successfully", + buttons: [AlertButton("OK")] + ) ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") { - if let result = scrapingModel.selectedSearchResult, + if let result = navModel.selectedSearchResult, let magnetLink = result.magnetLink, let url = URL(string: magnetLink) { @@ -82,12 +94,11 @@ struct MagnetChoiceView: View { } ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") { - if let result = scrapingModel.selectedSearchResult { - navModel.runMagnetAction(action: .webtor, searchResult: result) - } + navModel.runMagnetAction(magnetString: navModel.selectedSearchResult?.magnetLink, .webtor) } } } + .backport.tint(.primary) .sheet(isPresented: $navModel.showLocalActivitySheet) { if #available(iOS 16, *) { AppActivityView(activityItems: navModel.activityItems) @@ -96,6 +107,9 @@ struct MagnetChoiceView: View { AppActivityView(activityItems: navModel.activityItems) } } + .onDisappear { + debridManager.realDebridDownloadUrl = "" + } .navigationTitle("Link actions") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Ferrite/Views/MainView.swift b/Ferrite/Views/MainView.swift index 693795c..1a4da16 100644 --- a/Ferrite/Views/MainView.swift +++ b/Ferrite/Views/MainView.swift @@ -12,6 +12,8 @@ struct MainView: View { @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var toastModel: ToastViewModel @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var scrapingModel: ScrapingViewModel + @EnvironmentObject var backupManager: BackupManager @AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true @@ -28,6 +30,12 @@ struct MainView: View { } .tag(ViewTab.search) + LibraryView() + .tabItem { + Label("Library", systemImage: "book.closed") + } + .tag(ViewTab.library) + SourcesView() .tabItem { Label("Sources", systemImage: "doc.text") @@ -40,19 +48,26 @@ struct MainView: View { } .tag(ViewTab.settings) } - .alert(isPresented: $showUpdateAlert) { - Alert( - title: Text("Update available"), - message: Text("Ferrite \(releaseVersionString) can be downloaded. \n\n This alert can be disabled in Settings."), - primaryButton: .default(Text("Download")) { - guard let releaseUrl = URL(string: releaseUrlString) else { - return - } - - UIApplication.shared.open(releaseUrl) - }, - secondaryButton: .cancel() - ) + .sheet(item: $navModel.currentChoiceSheet) { item in + switch item { + case .magnet: + MagnetChoiceView() + .environmentObject(debridManager) + .environmentObject(scrapingModel) + .environmentObject(navModel) + case .batch: + BatchChoiceView() + .environmentObject(debridManager) + .environmentObject(scrapingModel) + .environmentObject(navModel) + case .activity: + if #available(iOS 16, *) { + AppActivityView(activityItems: navModel.activityItems) + .presentationDetents([.medium, .large]) + } else { + AppActivityView(activityItems: navModel.activityItems) + } + } } .onAppear { if autoUpdateNotifs { @@ -64,8 +79,7 @@ struct MainView: View { } let releaseVersion = String(latestRelease.tagName.dropFirst()) - if releaseVersion > UIApplication.shared.appVersion { - print("Greater") + if releaseVersion > Application.shared.appVersion { releaseVersionString = latestRelease.tagName releaseUrlString = latestRelease.htmlUrl showUpdateAlert.toggle() @@ -79,6 +93,52 @@ struct MainView: View { .onDisappear { viewTask?.cancel() } + .onOpenURL { url in + if url.scheme == "file" { + // Attempt to copy to backups directory if backup doesn't exist + backupManager.copyBackup(backupUrl: url) + + backupManager.showRestoreAlert.toggle() + } + } + // Global alerts for backups + .backport.alert( + isPresented: $backupManager.showRestoreAlert, + title: "Restore backup?", + message: "Restoring this backup will merge all your data!", + buttons: [ + .init("Restore", role: .destructive) { + backupManager.restoreBackup() + }, + .init(role: .cancel) + ] + ) + .backport.alert( + isPresented: $backupManager.showRestoreCompletedAlert, + title: "Backup restored", + message: backupManager.backupSourceNames.isEmpty ? + "No sources need to be reinstalled" : + "Reinstall sources: \(backupManager.backupSourceNames.joined(separator: ", "))", + buttons: [ + .init("OK") {} + ] + ) + // Updater alert + .backport.alert( + isPresented: $showUpdateAlert, + title: "Update available", + message: "Ferrite \(releaseVersionString) can be downloaded. \n\n This alert can be disabled in Settings.", + buttons: [ + AlertButton("Download") { + guard let releaseUrl = URL(string: releaseUrlString) else { + return + } + + UIApplication.shared.open(releaseUrl) + }, + AlertButton(role: .cancel) + ] + ) .overlay { VStack { Spacer() diff --git a/Ferrite/Views/RepresentableViews/WebView.swift b/Ferrite/Views/RepresentableViews/WebView.swift index 89301bb..9ef07fd 100644 --- a/Ferrite/Views/RepresentableViews/WebView.swift +++ b/Ferrite/Views/RepresentableViews/WebView.swift @@ -13,7 +13,7 @@ struct WebView: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let webView = WKWebView() - webView.load(URLRequest(url: url)) + let _ = webView.load(URLRequest(url: url)) return webView } diff --git a/Ferrite/Views/SearchProgressView.swift b/Ferrite/Views/SearchProgressView.swift deleted file mode 100644 index 0923ccb..0000000 --- a/Ferrite/Views/SearchProgressView.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// SearchProgressView.swift -// Ferrite -// -// Created by Brian Dashore on 8/8/22. -// - -import SwiftUI - -struct SearchProgressView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -struct SearchProgressView_Previews: PreviewProvider { - static var previews: some View { - SearchProgressView() - } -} diff --git a/Ferrite/Views/SearchResultViews/SearchResultButtonView.swift b/Ferrite/Views/SearchResultViews/SearchResultButtonView.swift new file mode 100644 index 0000000..9d9d984 --- /dev/null +++ b/Ferrite/Views/SearchResultViews/SearchResultButtonView.swift @@ -0,0 +1,134 @@ +// +// SearchResultButtonView.swift +// Ferrite +// +// Created by Brian Dashore on 9/2/22. +// + +import SwiftUI + +struct SearchResultButtonView: View { + let backgroundContext = PersistenceController.shared.backgroundContext + + @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager + + @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false + + var result: SearchResult + + @State private var runOnce = false + @State var existingBookmark: Bookmark? = nil + + var body: some View { + Button { + if debridManager.currentDebridTask == nil { + navModel.selectedSearchResult = result + navModel.selectedTitle = result.title + + switch debridManager.matchSearchResult(result: result) { + case .full: + if debridManager.setSelectedRdResult(result: result) { + debridManager.currentDebridTask = Task { + await debridManager.fetchRdDownload(searchResult: result) + + if !debridManager.realDebridDownloadUrl.isEmpty { + navModel.addToHistory(name: result.title, source: result.source, url: debridManager.realDebridDownloadUrl) + navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl) + + if navModel.currentChoiceSheet != .magnet { + debridManager.realDebridDownloadUrl = "" + } + } + } + } + case .partial: + if debridManager.setSelectedRdResult(result: result) { + navModel.currentChoiceSheet = .batch + } + case .none: + navModel.addToHistory(name: result.title, source: result.source, url: result.magnetLink) + navModel.runMagnetAction(magnetString: result.magnetLink) + } + } + } label: { + VStack(alignment: .leading, spacing: 10) { + Text(result.title ?? "No title") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(4) + + SearchResultRDView(result: result) + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + } + .disableInteraction(navModel.currentChoiceSheet != nil) + .backport.tint(.primary) + .conditionalContextMenu(id: existingBookmark) { + if let bookmark = existingBookmark { + Button { + PersistenceController.shared.delete(bookmark, context: backgroundContext) + + // When the entity is deleted, let other instances know to remove that reference + NotificationCenter.default.post(name: .didDeleteBookmark, object: nil) + } label: { + Text("Remove bookmark") + Image(systemName: "bookmark.slash.fill") + } + } else { + Button { + let newBookmark = Bookmark(context: backgroundContext) + newBookmark.title = result.title + newBookmark.source = result.source + newBookmark.magnetHash = result.magnetHash + newBookmark.magnetLink = result.magnetLink + newBookmark.seeders = result.seeders + newBookmark.leechers = result.leechers + + existingBookmark = newBookmark + + PersistenceController.shared.save(backgroundContext) + } label: { + Text("Bookmark") + Image(systemName: "bookmark") + } + } + } + .backport.alert( + isPresented: $debridManager.showDeleteAlert, + title: "Caching file", + message: "RealDebrid is currently caching this file. Would you like to delete it? \n\nProgress can be checked on the RealDebrid website.", + buttons: [ + AlertButton("Yes", role: .destructive) { + Task { + await debridManager.deleteRdTorrent() + } + }, + AlertButton(role: .cancel) + ] + ) + .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { _ in + existingBookmark = nil + } + .onAppear { + // Only run a exists request if a bookmark isn't passed to the view + if existingBookmark == nil, !runOnce { + let bookmarkRequest = Bookmark.fetchRequest() + bookmarkRequest.predicate = NSPredicate( + format: "title == %@ AND source == %@ AND magnetLink == %@ AND magnetHash = %@", + result.title ?? "", + result.source, + result.magnetLink ?? "", + result.magnetHash ?? "" + ) + bookmarkRequest.fetchLimit = 1 + + if let fetchedBookmark = try? backgroundContext.fetch(bookmarkRequest).first { + existingBookmark = fetchedBookmark + } + + runOnce = true + } + } + } +} diff --git a/Ferrite/Views/SearchResultRDView.swift b/Ferrite/Views/SearchResultViews/SearchResultRDView.swift similarity index 61% rename from Ferrite/Views/SearchResultRDView.swift rename to Ferrite/Views/SearchResultViews/SearchResultRDView.swift index dbcc913..8c51280 100644 --- a/Ferrite/Views/SearchResultRDView.swift +++ b/Ferrite/Views/SearchResultViews/SearchResultRDView.swift @@ -37,20 +37,18 @@ struct SearchResultRDView: View { .fontWeight(.bold) .padding(2) .background { - switch debridManager.matchSearchResult(result: result) { - case .full: - Color.green - .cornerRadius(4) - .opacity(0.5) - case .partial: - Color.orange - .cornerRadius(4) - .opacity(0.5) - case .none: - Color.red - .cornerRadius(4) - .opacity(0.5) + Group { + switch debridManager.matchSearchResult(result: result) { + case .full: + Color.green + case .partial: + Color.orange + case .none: + Color.red + } } + .cornerRadius(4) + .opacity(0.5) } } } diff --git a/Ferrite/Views/SearchResultsView.swift b/Ferrite/Views/SearchResultsView.swift index 4ca3a17..110813e 100644 --- a/Ferrite/Views/SearchResultsView.swift +++ b/Ferrite/Views/SearchResultsView.swift @@ -9,51 +9,18 @@ import SwiftUI struct SearchResultsView: View { @EnvironmentObject var scrapingModel: ScrapingViewModel - @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var navModel: NavigationViewModel - @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - var body: some View { List { ForEach(scrapingModel.searchResults, id: \.self) { result in if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil { - VStack(alignment: .leading) { - Button { - if debridManager.currentDebridTask == nil { - scrapingModel.selectedSearchResult = result - - switch debridManager.matchSearchResult(result: result) { - case .full: - debridManager.currentDebridTask = Task { - await debridManager.fetchRdDownload(searchResult: result) - - if !debridManager.realDebridDownloadUrl.isEmpty { - navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl) - } - } - case .partial: - if debridManager.setSelectedRdResult(result: result) { - navModel.currentChoiceSheet = .batch - } - case .none: - navModel.runMagnetAction(action: nil, searchResult: result) - } - } - } label: { - Text(result.title ?? "No title") - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - } - .dynamicAccentColor(.primary) - .padding(.bottom, 5) - - SearchResultRDView(result: result) - } + SearchResultButtonView(result: result) } } } .listStyle(.insetGrouped) + .inlinedList() .overlay { if scrapingModel.searchResults.isEmpty { if navModel.showSearchProgress { diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index 72bfa53..080445f 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -5,6 +5,7 @@ // Created by Brian Dashore on 7/11/22. // +import Introspect import SwiftUI struct SettingsView: View { @@ -21,7 +22,7 @@ struct SettingsView: View { var body: some View { NavView { Form { - Section(header: "Debrid services") { + Section(header: InlineHeader("Debrid Services")) { HStack { Text("Real Debrid") Spacer() @@ -36,15 +37,18 @@ struct SettingsView: View { } label: { Text(debridManager.realDebridEnabled ? "Logout" : (debridManager.realDebridAuthProcessing ? "Processing" : "Login")) .foregroundColor(debridManager.realDebridEnabled ? .red : .blue) + .onChange(of: debridManager.realDebridEnabled) { changed in + print("Debrid enabled changed to \(changed)") + } } } } - Section(header: "Source management") { + Section(header: Text("Source management")) { NavigationLink("Source lists", destination: SettingsSourceListView()) } - Section(header: "Default actions") { + Section(header: Text("Default actions")) { if debridManager.realDebridEnabled { NavigationLink( destination: DebridActionPickerView(), @@ -94,6 +98,12 @@ struct SettingsView: View { ) } + Section(header: Text("Backups")) { + NavigationLink(destination: BackupsView()) { + Text("Backups") + } + } + Section(header: Text("Updates")) { Toggle(isOn: $autoUpdateNotifs) { Text("Show update alerts") diff --git a/Ferrite/Views/SettingsViews/BackupsView.swift b/Ferrite/Views/SettingsViews/BackupsView.swift new file mode 100644 index 0000000..6a91153 --- /dev/null +++ b/Ferrite/Views/SettingsViews/BackupsView.swift @@ -0,0 +1,73 @@ +// +// BackupsView.swift +// Ferrite +// +// Created by Brian Dashore on 9/16/22. +// + +import SwiftUI + +struct BackupsView: View { + @EnvironmentObject var backupManager: BackupManager + @EnvironmentObject var navModel: NavigationViewModel + + @State private var selectedBackupUrl: URL? + @State private var showRestoreAlert = false + @State private var showRestoreCompletedAlert = false + + var body: some View { + ZStack { + if backupManager.backupUrls.isEmpty { + EmptyInstructionView(title: "No Backups", message: "Create one using the + button in the top-right") + } else { + List { + ForEach(backupManager.backupUrls, id: \.self) { url in + Button(url.lastPathComponent) { + backupManager.selectedBackupUrl = url + backupManager.showRestoreAlert.toggle() + } + .contextMenu { + Button { + navModel.activityItems = [url] + navModel.currentChoiceSheet = .activity + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + } + .backport.tint(.primary) + } + .onDelete { offsets in + for index in offsets { + if let url = backupManager.backupUrls[safe: index] { + backupManager.removeBackup(backupUrl: url, index: index) + } + } + } + } + .inlinedList() + .listStyle(.insetGrouped) + } + } + .onAppear { + backupManager.backupUrls = FileManager.default.appDirectory + .appendingPathComponent("Backups", isDirectory: true).contentsByDateAdded + } + .navigationTitle("Backups") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + backupManager.createBackup() + } label: { + Image(systemName: "plus") + } + } + } + } +} + +struct BackupsView_Previews: PreviewProvider { + static var previews: some View { + BackupsView() + } +} diff --git a/Ferrite/Views/SettingsViews/DefaultActionsPickerViews.swift b/Ferrite/Views/SettingsViews/DefaultActionsPickerViews.swift index 8eb1c8d..c9c069c 100644 --- a/Ferrite/Views/SettingsViews/DefaultActionsPickerViews.swift +++ b/Ferrite/Views/SettingsViews/DefaultActionsPickerViews.swift @@ -25,10 +25,11 @@ struct MagnetActionPickerView: View { } } } - .dynamicAccentColor(.primary) + .backport.tint(.primary) } } .listStyle(.insetGrouped) + .inlinedList() .navigationTitle("Default magnet action") .navigationBarTitleDisplayMode(.inline) } @@ -63,10 +64,11 @@ struct DebridActionPickerView: View { } } } - .dynamicAccentColor(.primary) + .backport.tint(.primary) } } .listStyle(.insetGrouped) + .inlinedList() .navigationTitle("Default debrid action") .navigationBarTitleDisplayMode(.inline) } diff --git a/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift b/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift index eaf87eb..f15f15d 100644 --- a/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift +++ b/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift @@ -13,12 +13,21 @@ struct SettingsAppVersionView: View { @State private var viewTask: Task? @State private var releases: [GithubRelease] = [] + @State private var loadedReleases = false + var body: some View { - List { - Section(header: Text("GitHub links")) { - ForEach(releases, id: \.self) { release in - ListRowLinkView(text: release.tagName, link: release.htmlUrl) + ZStack { + if !loadedReleases { + ProgressView() + } else if !releases.isEmpty { + List { + Section(header: InlineHeader("GitHub links")) { + ForEach(releases, id: \.self) { release in + ListRowLinkView(text: release.tagName, link: release.htmlUrl) + } + } } + .listStyle(.insetGrouped) } } .onAppear { @@ -32,13 +41,16 @@ struct SettingsAppVersionView: View { } catch { toastModel.updateToastDescription("Github error: \(error)") } + + withAnimation { + loadedReleases = true + } } } .onDisappear { viewTask?.cancel() } - .listStyle(.insetGrouped) - .navigationTitle("Version history") + .navigationTitle("Version History") .navigationBarTitleDisplayMode(.inline) } } diff --git a/Ferrite/Views/SettingsViews/SettingsSourceListView.swift b/Ferrite/Views/SettingsViews/SettingsSourceListView.swift index 38f3618..7ab13d4 100644 --- a/Ferrite/Views/SettingsViews/SettingsSourceListView.swift +++ b/Ferrite/Views/SettingsViews/SettingsSourceListView.swift @@ -21,39 +21,61 @@ struct SettingsSourceListView: View { @State private var selectedSourceList: SourceList? var body: some View { - List { - Section(header: Text("List information")) { - ForEach(sourceLists, id: \.self) { sourceList in - VStack(alignment: .leading, spacing: 5) { - Text(sourceList.name) + ZStack { + if sourceLists.isEmpty { + EmptyInstructionView(title: "No Lists", message: "Add a source list using the + button in the top-right") + } else { + List { + ForEach(sourceLists, id: \.self) { sourceList in + VStack(alignment: .leading, spacing: 5) { + Text(sourceList.name) - Text(sourceList.author) - .foregroundColor(.gray) + Text(sourceList.author) + .foregroundColor(.gray) - Text("ID: \(sourceList.id)") - .font(.caption) - .foregroundColor(.gray) - } - .contextMenu { - Button { - navModel.selectedSourceList = sourceList - presentSourceSheet.toggle() - } label: { - Text("Edit") - Image(systemName: "pencil") + Text("ID: \(sourceList.id)") + .font(.caption) + .foregroundColor(.gray) } + .padding(.vertical, 2) + .contextMenu { + Button { + navModel.selectedSourceList = sourceList + presentSourceSheet.toggle() + } label: { + Text("Edit") + Image(systemName: "pencil") + } - Button { - PersistenceController.shared.delete(sourceList, context: backgroundContext) - } label: { - Text("Remove") - Image(systemName: "trash") + if #available(iOS 15.0, *) { + Button(role: .destructive) { + PersistenceController.shared.delete(sourceList, context: backgroundContext) + } label: { + Text("Remove") + Image(systemName: "trash") + } + } else { + Button { + PersistenceController.shared.delete(sourceList, context: backgroundContext) + } label: { + Text("Remove") + Image(systemName: "trash") + } + } + } + } + .onDelete { offsets in + for index in offsets { + if let list = sourceLists[safe: index] { + PersistenceController.shared.delete(list, context: backgroundContext) + } } } } + .listStyle(.insetGrouped) + .inlinedList() } } - .listStyle(.insetGrouped) .sheet(isPresented: $presentSourceSheet) { if #available(iOS 16, *) { SourceListEditorView() @@ -62,7 +84,7 @@ struct SettingsSourceListView: View { SourceListEditorView() } } - .navigationTitle("Source lists") + .navigationTitle("Source Lists") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { diff --git a/Ferrite/Views/SettingsViews/SourceListEditorView.swift b/Ferrite/Views/SettingsViews/SourceListEditorView.swift index 9db11c7..a7fd295 100644 --- a/Ferrite/Views/SettingsViews/SourceListEditorView.swift +++ b/Ferrite/Views/SettingsViews/SourceListEditorView.swift @@ -15,28 +15,29 @@ struct SourceListEditorView: View { let backgroundContext = PersistenceController.shared.backgroundContext - @State private var sourceUrl = "" + @State private var sourceUrlSet = false + + @State private var sourceUrl: String = "" var body: some View { NavView { Form { - Section { - TextField("Enter URL", text: $sourceUrl) - .disableAutocorrection(true) - .keyboardType(.URL) - .autocapitalization(.none) - } + TextField("Enter URL", text: $sourceUrl) + .disableAutocorrection(true) + .keyboardType(.URL) + .autocapitalization(.none) + .conditionalId(sourceUrlSet) } .onAppear { sourceUrl = navModel.selectedSourceList?.urlString ?? "" + sourceUrlSet = true } - .alert(isPresented: $sourceManager.showUrlErrorAlert) { - Alert( - title: Text("Error"), - message: Text(sourceManager.urlErrorAlertText), - dismissButton: .default(Text("OK")) - ) - } + .backport.alert( + isPresented: $sourceManager.showUrlErrorAlert, + title: "Error", + message: sourceManager.urlErrorAlertText, + buttons: [AlertButton("OK")] + ) .navigationTitle("Editing source list") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Ferrite/Views/SourceViews/InstalledSourceView.swift b/Ferrite/Views/SourceViews/Buttons/InstalledSourceButtonView.swift similarity index 63% rename from Ferrite/Views/SourceViews/InstalledSourceView.swift rename to Ferrite/Views/SourceViews/Buttons/InstalledSourceButtonView.swift index 429ab9e..3564629 100644 --- a/Ferrite/Views/SourceViews/InstalledSourceView.swift +++ b/Ferrite/Views/SourceViews/Buttons/InstalledSourceButtonView.swift @@ -1,5 +1,5 @@ // -// InstalledSourceView.swift +// InstalledSourceButtonView.swift // Ferrite // // Created by Brian Dashore on 8/5/22. @@ -7,7 +7,7 @@ import SwiftUI -struct InstalledSourceView: View { +struct InstalledSourceButtonView: View { let backgroundContext = PersistenceController.shared.backgroundContext @EnvironmentObject var navModel: NavigationViewModel @@ -32,6 +32,7 @@ struct InstalledSourceView: View { Text("by \(installedSource.author)") .foregroundColor(.secondary) } + .padding(.vertical, 2) } .contextMenu { Button { @@ -42,11 +43,20 @@ struct InstalledSourceView: View { Image(systemName: "gear") } - Button { - PersistenceController.shared.delete(installedSource, context: backgroundContext) - } label: { - Text("Remove") - Image(systemName: "trash") + if #available(iOS 15.0, *) { + Button(role: .destructive) { + PersistenceController.shared.delete(installedSource, context: backgroundContext) + } label: { + Text("Remove") + Image(systemName: "trash") + } + } else { + Button { + PersistenceController.shared.delete(installedSource, context: backgroundContext) + } label: { + Text("Remove") + Image(systemName: "trash") + } } } } diff --git a/Ferrite/Views/SourceViews/SourceCatalogView.swift b/Ferrite/Views/SourceViews/Buttons/SourceCatalogButtonView.swift similarity index 96% rename from Ferrite/Views/SourceViews/SourceCatalogView.swift rename to Ferrite/Views/SourceViews/Buttons/SourceCatalogButtonView.swift index 9566df8..cdfa129 100644 --- a/Ferrite/Views/SourceViews/SourceCatalogView.swift +++ b/Ferrite/Views/SourceViews/Buttons/SourceCatalogButtonView.swift @@ -33,5 +33,6 @@ struct SourceCatalogButtonView: View { } } } + .padding(.vertical, 2) } } diff --git a/Ferrite/Views/SourceViews/SourceUpdateButtonView.swift b/Ferrite/Views/SourceViews/Buttons/SourceUpdateButtonView.swift similarity index 96% rename from Ferrite/Views/SourceViews/SourceUpdateButtonView.swift rename to Ferrite/Views/SourceViews/Buttons/SourceUpdateButtonView.swift index 029788b..2b0f1f2 100644 --- a/Ferrite/Views/SourceViews/SourceUpdateButtonView.swift +++ b/Ferrite/Views/SourceViews/Buttons/SourceUpdateButtonView.swift @@ -24,6 +24,7 @@ struct SourceUpdateButtonView: View { Text("by \(updatedSource.author ?? "Unknown")") .foregroundColor(.secondary) } + .padding(.vertical, 2) Spacer() diff --git a/Ferrite/Views/SourceViews/SourceSettingsView.swift b/Ferrite/Views/SourceViews/SourceSettingsView.swift index 7f8f926..0936658 100644 --- a/Ferrite/Views/SourceViews/SourceSettingsView.swift +++ b/Ferrite/Views/SourceViews/SourceSettingsView.swift @@ -16,7 +16,7 @@ struct SourceSettingsView: View { NavView { List { if let selectedSource = navModel.selectedSource { - Section(header: "Info") { + Section(header: InlineHeader("Info")) { VStack(alignment: .leading, spacing: 5) { HStack { Text(selectedSource.name) @@ -40,6 +40,7 @@ struct SourceSettingsView: View { .foregroundColor(.secondary) .font(.caption) } + .padding(.vertical, 2) } if selectedSource.dynamicBaseUrl { @@ -77,7 +78,7 @@ struct SourceSettingsBaseUrlView: View { @State private var tempBaseUrl: String = "" var body: some View { Section( - header: Text("Base URL"), + header: InlineHeader("Base URL"), footer: Text("Enter the base URL of your server.") ) { TextField("https://...", text: $tempBaseUrl, onEditingChanged: { isFocused in @@ -109,7 +110,7 @@ struct SourceSettingsApiView: View { var body: some View { Section( - header: Text("API credentials"), + header: InlineHeader("API credentials"), footer: Text("Grab the required API credentials from the website. A client secret can be an API token.") ) { if let clientId = selectedSourceApi.clientId, clientId.dynamic { @@ -145,8 +146,8 @@ struct SourceSettingsMethodView: View { @ObservedObject var selectedSource: Source var body: some View { - Section(header: Text("Fetch method")) { - if selectedSource.api != nil, selectedSource.jsonParser != nil { + Section(header: InlineHeader("Fetch method")) { + if selectedSource.jsonParser != nil { Button { selectedSource.preferredParser = SourcePreferredParser.siteApi.rawValue } label: { @@ -191,6 +192,6 @@ struct SourceSettingsMethodView: View { } } } - .dynamicAccentColor(.primary) + .backport.tint(.primary) } } diff --git a/Ferrite/Views/SourcesView.swift b/Ferrite/Views/SourcesView.swift index 570c92b..aa60a27 100644 --- a/Ferrite/Views/SourcesView.swift +++ b/Ferrite/Views/SourcesView.swift @@ -5,7 +5,9 @@ // Created by Brian Dashore on 7/24/22. // +import Introspect import SwiftUI +import SwiftUIX struct SourcesView: View { @EnvironmentObject var sourceManager: SourceManager @@ -18,83 +20,112 @@ struct SourcesView: View { sortDescriptors: [] ) var sources: FetchedResults - private var updatedSources: [SourceJson] { - var tempSources: [SourceJson] = [] - - for source in sources { - guard let availableSource = sourceManager.availableSources.first(where: { - source.listId == $0.listId && source.name == $0.name && source.author == $0.author - }) else { - continue - } - - if availableSource.version > source.version { - tempSources.append(availableSource) - } - } - - return tempSources - } + @State private var checkedForSources = false + @State private var isEditing = false @State private var viewTask: Task? = nil + @State private var searchText: String = "" + @State private var filteredUpdatedSources: [SourceJson] = [] + @State private var filteredAvailableSources: [SourceJson] = [] + @State private var sourcePredicate: NSPredicate? var body: some View { NavView { - List { - if !updatedSources.isEmpty { - Section(header: "Updates") { - ForEach(updatedSources, id: \.self) { source in - SourceUpdateButtonView(updatedSource: source) + DynamicFetchRequest(predicate: sourcePredicate) { (installedSources: FetchedResults) in + ZStack { + if !checkedForSources { + ProgressView() + } else if sources.isEmpty, sourceManager.availableSources.isEmpty { + EmptyInstructionView(title: "No Sources", message: "Add a source list in Settings") + } else { + List { + if !filteredUpdatedSources.isEmpty { + Section(header: InlineHeader("Updates")) { + ForEach(filteredUpdatedSources, id: \.self) { source in + SourceUpdateButtonView(updatedSource: source) + } + } + } + + if !installedSources.isEmpty { + Section(header: InlineHeader("Installed")) { + ForEach(installedSources, id: \.self) { source in + InstalledSourceButtonView(installedSource: source) + } + } + } + + if !filteredAvailableSources.isEmpty { + Section(header: InlineHeader("Catalog")) { + ForEach(filteredAvailableSources, id: \.self) { availableSource in + if !installedSources.contains(where: { + availableSource.name == $0.name && + availableSource.listId == $0.listId && + availableSource.author == $0.author + }) { + SourceCatalogButtonView(availableSource: availableSource) + } + } + } + } } + .conditionalId(UUID()) + .listStyle(.insetGrouped) } } - - if !sources.isEmpty { - Section(header: "Installed") { - ForEach(sources, id: \.self) { source in - InstalledSourceView(installedSource: source) + .sheet(isPresented: $navModel.showSourceSettings) { + SourceSettingsView() + .environmentObject(navModel) + } + .onAppear { + viewTask = Task { + await sourceManager.fetchSourcesFromUrl() + filteredAvailableSources = sourceManager.availableSources.filter { availableSource in + !installedSources.contains(where: { + availableSource.name == $0.name && + availableSource.listId == $0.listId && + availableSource.author == $0.author + }) } + + filteredUpdatedSources = sourceManager.fetchUpdatedSources(installedSources: installedSources) + checkedForSources = true } } - - if sourceManager.availableSources.contains(where: { availableSource in - !sources.contains( - where: { + .onDisappear { + viewTask?.cancel() + } + .onChange(of: searchText) { _ in + sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText) + } + .onReceive(installedSources.publisher.count()) { _ in + filteredAvailableSources = sourceManager.availableSources.filter { availableSource in + let sourceExists = installedSources.contains(where: { availableSource.name == $0.name && availableSource.listId == $0.listId && availableSource.author == $0.author - } - ) - }) { - Section(header: "Catalog") { - ForEach(sourceManager.availableSources, id: \.self) { availableSource in - if !sources.contains( - where: { - availableSource.name == $0.name && - availableSource.listId == $0.listId && - availableSource.author == $0.author - } - ) { - SourceCatalogButtonView(availableSource: availableSource) - } + }) + + if searchText.isEmpty { + return !sourceExists + } else { + return !sourceExists && availableSource.name.lowercased().contains(searchText.lowercased()) } } + + filteredUpdatedSources = sourceManager.fetchUpdatedSources(installedSources: installedSources).filter { + searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased()) + } + } + .navigationTitle("Sources") + .navigationSearchBar { + SearchBar("Search", text: $searchText, isEditing: $isEditing) + .showsCancelButton(isEditing) + .onCancel { + searchText = "" + } } } - .listStyle(.insetGrouped) - .sheet(isPresented: $navModel.showSourceSettings) { - SourceSettingsView() - .environmentObject(navModel) - } - .onAppear { - viewTask = Task { - await sourceManager.fetchSourcesFromUrl() - } - } - .onDisappear { - viewTask?.cancel() - } - .navigationTitle("Sources") } } }