Ferrite: Add actions, plugins, and tags

Plugins are now a unified format for both sources and actions. Actions
dictate what to do with a link and can now be added through a plugin
JSON file.

Backups have also been versioned to improve performance and add action
support.

Tags are used to give small amounts of information before a user
installs a plugin.

Signed-off-by: kingbri <bdashore3@proton.me>
This commit is contained in:
kingbri 2023-02-08 12:09:37 -05:00
parent 6b0f90178b
commit 4512318e8f
51 changed files with 1470 additions and 609 deletions

View file

@ -8,20 +8,26 @@
/* Begin PBXBuildFile section */
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0167DB29293FA900B65783 /* RealDebridModels.swift */; };
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB6F296F619900162E9A /* PluginList+CoreDataClass.swift */; };
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; };
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginListView.swift */; };
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; };
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */; };
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; };
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.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 */; };
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 */; };
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */; };
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */; };
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */; };
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */; };
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; };
@ -35,8 +41,13 @@
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 */; };
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5005512992B6750064606A /* PluginTagsView.swift */; };
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */; };
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5005592992BA6A0064606A /* PluginTag+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 */; };
0C572D4C2993FC2A003EEC05 /* OnAppearHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4B2993FC2A003EEC05 /* OnAppearHandler.swift */; };
0C572D4E299403B7003EEC05 /* DidAppearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4D299403B7003EEC05 /* DidAppearModifier.swift */; };
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; };
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */; };
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
@ -53,9 +64,8 @@
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */; };
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 /* InstalledSourceButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */; };
0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */; };
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */; };
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.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 */; };
@ -68,16 +78,14 @@
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; };
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */; };
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */; };
0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */; };
0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */; };
0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */; };
0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */; };
0CA05457288EE58200850554 /* SettingsSourceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsSourceListView.swift */; };
0CA05459288EE9E600850554 /* SourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* SourceManager.swift */; };
0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */; };
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsPluginListView.swift */; };
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* PluginManager.swift */; };
0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */; };
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; };
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; };
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */; };
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* ActionChoiceView.swift */; };
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 */; };
@ -110,30 +118,40 @@
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 */; };
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; };
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; };
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; };
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD72E16293D9928001A7EA4 /* Array.swift */; };
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; };
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */ = {isa = PBXBuildFile; productRef = 0CDDDE042935235E006810B1 /* BetterSafariView */; };
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.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 = "<group>"; };
0C03EB6F296F619900162E9A /* PluginList+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataClass.swift"; sourceTree = "<group>"; };
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListView.swift; sourceTree = "<group>"; };
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = "<group>"; };
0C12D43C28CC332A000195BF /* HistoryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryButtonView.swift; sourceTree = "<group>"; };
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = "<group>"; };
0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; };
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = "<group>"; };
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionModels.swift; sourceTree = "<group>"; };
0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsView.swift; sourceTree = "<group>"; };
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginModels.swift; sourceTree = "<group>"; };
0C3E00D9296F5E4F00ECECB2 /* FerriteDB_v2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB_v2.xcdatamodel; sourceTree = "<group>"; };
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultButtonView.swift; sourceTree = "<group>"; };
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; };
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = "<group>"; };
@ -146,8 +164,13 @@
0C44E2AE28D52E8A007711AE /* BackupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsView.swift; sourceTree = "<group>"; };
0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataClass.swift"; sourceTree = "<group>"; };
0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C5005512992B6750064606A /* PluginTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginTagsView.swift; sourceTree = "<group>"; };
0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginTag+CoreDataClass.swift"; sourceTree = "<group>"; };
0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginTag+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; };
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C572D4B2993FC2A003EEC05 /* OnAppearHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnAppearHandler.swift; sourceTree = "<group>"; };
0C572D4D299403B7003EEC05 /* DidAppearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DidAppearModifier.swift; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = "<group>"; };
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = "<group>"; };
@ -160,9 +183,8 @@
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C78041C28BFB3EA001E8CA3 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceUpdateButtonView.swift; sourceTree = "<group>"; };
0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledSourceButtonView.swift; sourceTree = "<group>"; };
0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceCatalogButtonView.swift; sourceTree = "<group>"; };
0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledPluginButtonView.swift; sourceTree = "<group>"; };
0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginCatalogButtonView.swift; sourceTree = "<group>"; };
0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataClass.swift"; sourceTree = "<group>"; };
0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataProperties.swift"; sourceTree = "<group>"; };
@ -175,16 +197,14 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceList+CoreDataClass.swift"; sourceTree = "<group>"; };
0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceList+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionsPickerViews.swift; sourceTree = "<group>"; };
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
0CA05456288EE58200850554 /* SettingsSourceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSourceListView.swift; sourceTree = "<group>"; };
0CA05458288EE9E600850554 /* SourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceManager.swift; sourceTree = "<group>"; };
0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceListEditorView.swift; sourceTree = "<group>"; };
0CA05456288EE58200850554 /* SettingsPluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPluginListView.swift; sourceTree = "<group>"; };
0CA05458288EE9E600850554 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListEditorView.swift; sourceTree = "<group>"; };
0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = "<group>"; };
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagnetChoiceView.swift; sourceTree = "<group>"; };
0CA148BD288903F000DE2211 /* ActionChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionChoiceView.swift; sourceTree = "<group>"; };
0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = "<group>"; };
0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = "<group>"; };
@ -216,11 +236,14 @@
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = "<group>"; };
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; };
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = "<group>"; };
0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = "<group>"; };
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; };
0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -246,7 +269,7 @@
0C0755C22934241F00ECA142 /* SheetViews */ = {
isa = PBXGroup;
children = (
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */,
0CA148BD288903F000DE2211 /* ActionChoiceView.swift */,
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */,
);
path = SheetViews;
@ -255,11 +278,11 @@
0C0755C32934244500ECA142 /* ComponentViews */ = {
isa = PBXGroup;
children = (
0C3E00D4296F560800ECECB2 /* Plugin */,
0C0755C42934245800ECA142 /* Debrid */,
0CA3B23528C265FD00616D3A /* Library */,
0C44E2AB28D4E126007711AE /* SearchResult */,
0CA0545C288F7CB200850554 /* Settings */,
0C794B65289DAC9F00DD1CC8 /* Source */,
);
path = ComponentViews;
sourceTree = "<group>";
@ -276,6 +299,12 @@
0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup;
children = (
0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */,
0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */,
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */,
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */,
0C03EB6F296F619900162E9A /* PluginList+CoreDataClass.swift */,
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */,
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */,
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */,
0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */,
@ -292,8 +321,6 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */,
0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */,
);
path = Classes;
sourceTree = "<group>";
@ -301,6 +328,7 @@
0C0D50E3288DFE6E0035ECC8 /* Models */ = {
isa = PBXGroup;
children = (
0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */,
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
@ -310,6 +338,7 @@
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */,
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */,
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
);
path = Models;
sourceTree = "<group>";
@ -324,6 +353,17 @@
path = Cloud;
sourceTree = "<group>";
};
0C3E00D4296F560800ECECB2 /* Plugin */ = {
isa = PBXGroup;
children = (
0C44E2AA28D4E09B007711AE /* Buttons */,
0C794B65289DAC9F00DD1CC8 /* Source */,
0C0D50E6288DFF850035ECC8 /* PluginListView.swift */,
0C5005512992B6750064606A /* PluginTagsView.swift */,
);
path = Plugin;
sourceTree = "<group>";
};
0C44E2A628D4DDC6007711AE /* Classes */ = {
isa = PBXGroup;
children = (
@ -340,6 +380,7 @@
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
0C572D4D299403B7003EEC05 /* DidAppearModifier.swift */,
);
path = Modifiers;
sourceTree = "<group>";
@ -347,9 +388,8 @@
0C44E2AA28D4E09B007711AE /* Buttons */ = {
isa = PBXGroup;
children = (
0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */,
0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */,
0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */,
0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */,
0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.swift */,
);
path = Buttons;
sourceTree = "<group>";
@ -363,10 +403,17 @@
path = SearchResult;
sourceTree = "<group>";
};
0C5005552992B9C20064606A /* Protocols */ = {
isa = PBXGroup;
children = (
0CE1C4172981E8D700418F20 /* Plugin.swift */,
);
path = Protocols;
sourceTree = "<group>";
};
0C794B65289DAC9F00DD1CC8 /* Source */ = {
isa = PBXGroup;
children = (
0C44E2AA28D4E09B007711AE /* Buttons */,
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
);
path = Source;
@ -376,8 +423,8 @@
isa = PBXGroup;
children = (
0C44E2AE28D52E8A007711AE /* BackupsView.swift */,
0CA05456288EE58200850554 /* SettingsSourceListView.swift */,
0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */,
0CA05456288EE58200850554 /* SettingsPluginListView.swift */,
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */,
0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */,
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
);
@ -394,6 +441,7 @@
0CA148EF2889061600DE2211 /* ViewModels */,
0CA148EE2889061200DE2211 /* Views */,
0C44E2A628D4DDC6007711AE /* Classes */,
0C5005552992B9C20064606A /* Protocols */,
0CA148C8288903F000DE2211 /* Extensions */,
0CA148C5288903F000DE2211 /* Preview Content */,
0CA148C7288903F000DE2211 /* FerriteApp.swift */,
@ -415,6 +463,7 @@
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
0C2D9652299316CC00A504B6 /* Tag.swift */,
);
path = CommonViews;
sourceTree = "<group>";
@ -457,7 +506,7 @@
0CA148D4288903F000DE2211 /* ContentView.swift */,
0CA148D3288903F000DE2211 /* SearchResultsView.swift */,
0CA3B23328C2658700616D3A /* LibraryView.swift */,
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */,
0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */,
0CA148BB288903F000DE2211 /* SettingsView.swift */,
0C32FB522890D19D002BD219 /* AboutView.swift */,
0CA148BC288903F000DE2211 /* LoginWebView.swift */,
@ -472,7 +521,7 @@
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */,
0CA148CF288903F000DE2211 /* ToastViewModel.swift */,
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */,
0CA05458288EE9E600850554 /* SourceManager.swift */,
0CA05458288EE9E600850554 /* PluginManager.swift */,
0C44E2AC28D51C63007711AE /* BackupManager.swift */,
);
path = ViewModels;
@ -482,6 +531,7 @@
isa = PBXGroup;
children = (
0CA148CE288903F000DE2211 /* WebView.swift */,
0C572D4B2993FC2A003EEC05 /* OnAppearHandler.swift */,
);
path = RepresentableViews;
sourceTree = "<group>";
@ -652,46 +702,55 @@
buildActionMask = 2147483647;
files = (
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
0C0D50E5288DFE7F0035ECC8 /* SourceModels.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 */,
0C2D9653299316CC00A504B6 /* Tag.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 */,
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */,
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */,
0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */,
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */,
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */,
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */,
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* PluginListView.swift in Sources */,
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */,
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */,
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
0C572D4C2993FC2A003EEC05 /* OnAppearHandler.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 /* InstalledSourceButtonView.swift in Sources */,
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */,
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */,
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */,
@ -699,8 +758,7 @@
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */,
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */,
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */,
0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */,
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */,
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */,
@ -712,7 +770,6 @@
0C5FCB05296744F300849E87 /* AllDebridCloudView.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 */,
@ -722,23 +779,27 @@
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */,
0C42B5982932F6DD008057A0 /* Set.swift in Sources */,
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
0CA05459288EE9E600850554 /* SourceManager.swift in Sources */,
0CA05459288EE9E600850554 /* PluginManager.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 */,
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
0C572D4E299403B7003EEC05 /* DidAppearModifier.swift in Sources */,
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */,
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
@ -1076,9 +1137,10 @@
0C84F4752895BE680074B7C9 /* FerriteDB.xcdatamodeld */ = {
isa = XCVersionGroup;
children = (
0C3E00D9296F5E4F00ECECB2 /* FerriteDB_v2.xcdatamodel */,
0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */,
);
currentVersion = 0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */;
currentVersion = 0C3E00D9296F5E4F00ECECB2 /* FerriteDB_v2.xcdatamodel */;
path = FerriteDB.xcdatamodeld;
sourceTree = "<group>";
versionGroupType = wrapper.xcdatamodel;

View file

@ -0,0 +1,13 @@
//
// Action+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 1/12/23.
//
//
import Foundation
import CoreData
@objc(Action)
public class Action: NSManagedObject, Plugin {}

View file

@ -0,0 +1,71 @@
//
// Action+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 2/6/23.
//
//
import Foundation
import CoreData
extension Action {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Action> {
return NSFetchRequest<Action>(entityName: "Action")
}
@NSManaged public var id: UUID
@NSManaged public var listId: UUID?
@NSManaged public var name: String
@NSManaged public var deeplink: String?
@NSManaged public var version: Int16
@NSManaged public var requires: [String]
@NSManaged public var author: String
@NSManaged public var enabled: Bool
@NSManaged public var tags: NSOrderedSet?
public func getTags() -> [PluginTagJson] {
return requires.map { PluginTagJson(name: $0, colorHex: nil) } + tagArray.map { $0.toJson() }
}
}
// MARK: Generated accessors for tags
extension Action {
@objc(insertObject:inTagsAtIndex:)
@NSManaged public func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged public func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged public func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged public func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged public func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged public func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged public func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged public func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged public func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged public func removeFromTags(_ values: NSOrderedSet)
}
extension Action : Identifiable {
}

View file

@ -10,15 +10,4 @@ import CoreData
import Foundation
@objc(Bookmark)
public class Bookmark: NSManagedObject {
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnet: Magnet(hash: magnetHash, link: magnetLink),
seeders: seeders,
leechers: leechers
)
}
}
public class Bookmark: NSManagedObject {}

View file

@ -22,6 +22,17 @@ public extension Bookmark {
@NSManaged var source: String
@NSManaged var title: String?
@NSManaged var orderNum: Int16
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnet: Magnet(hash: magnetHash, link: magnetLink),
seeders: seeders,
leechers: leechers
)
}
}
extension Bookmark: Identifiable {}

View file

@ -0,0 +1,15 @@
//
// PluginList+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
//
import Foundation
import CoreData
@objc(PluginList)
public class PluginList: NSManagedObject {
}

View file

@ -0,0 +1,28 @@
//
// PluginList+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
//
import Foundation
import CoreData
extension PluginList {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PluginList> {
return NSFetchRequest<PluginList>(entityName: "PluginList")
}
@NSManaged public var author: String
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var urlString: String
}
extension PluginList : Identifiable {
}

View file

@ -0,0 +1,14 @@
//
// PluginTag+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
//
import Foundation
import CoreData
@objc(PluginTag)
public class PluginTag: NSManagedObject {
}

View file

@ -0,0 +1,31 @@
//
// PluginTag+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
//
import Foundation
import CoreData
extension PluginTag {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PluginTag> {
return NSFetchRequest<PluginTag>(entityName: "PluginTag")
}
@NSManaged public var colorHex: String?
@NSManaged public var name: String
@NSManaged public var parentAction: Action?
@NSManaged public var parentSource: Source?
func toJson() -> PluginTagJson {
return PluginTagJson(name: name, colorHex: colorHex)
}
}
extension PluginTag : Identifiable {
}

View file

@ -10,4 +10,4 @@ import CoreData
import Foundation
@objc(Source)
public class Source: NSManagedObject {}
public class Source: NSManagedObject, Plugin {}

View file

@ -2,33 +2,77 @@
// Source+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 8/3/22.
// Created by Brian Dashore on 2/6/23.
//
//
import CoreData
import Foundation
import CoreData
public extension Source {
@nonobjc class func fetchRequest() -> NSFetchRequest<Source> {
NSFetchRequest<Source>(entityName: "Source")
extension Source {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Source> {
return NSFetchRequest<Source>(entityName: "Source")
}
@NSManaged var id: UUID
@NSManaged var baseUrl: String?
@NSManaged var fallbackUrls: [String]?
@NSManaged var dynamicBaseUrl: Bool
@NSManaged var enabled: Bool
@NSManaged var name: String
@NSManaged var author: String
@NSManaged var listId: UUID?
@NSManaged var preferredParser: Int16
@NSManaged var version: Int16
@NSManaged var htmlParser: SourceHtmlParser?
@NSManaged var rssParser: SourceRssParser?
@NSManaged var jsonParser: SourceJsonParser?
@NSManaged var api: SourceApi?
@NSManaged var trackers: [String]?
@NSManaged public var id: UUID
@NSManaged public var baseUrl: String?
@NSManaged public var fallbackUrls: [String]?
@NSManaged public var dynamicBaseUrl: Bool
@NSManaged public var enabled: Bool
@NSManaged public var name: String
@NSManaged public var author: String
@NSManaged public var listId: UUID?
@NSManaged public var preferredParser: Int16
@NSManaged public var version: Int16
@NSManaged public var htmlParser: SourceHtmlParser?
@NSManaged public var rssParser: SourceRssParser?
@NSManaged public var jsonParser: SourceJsonParser?
@NSManaged public var api: SourceApi?
@NSManaged public var trackers: [String]?
@NSManaged public var tags: NSOrderedSet?
public func getTags() -> [PluginTagJson] {
return tagArray.map { $0.toJson() }
}
}
extension Source: Identifiable {}
// MARK: Generated accessors for tags
extension Source {
@objc(insertObject:inTagsAtIndex:)
@NSManaged public func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged public func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged public func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged public func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged public func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged public func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged public func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged public func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged public func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged public func removeFromTags(_ values: NSOrderedSet)
}
extension Source : Identifiable {
}

View file

@ -1,13 +0,0 @@
//
// SourceList+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 7/30/22.
//
//
import CoreData
import Foundation
@objc(SourceList)
public class SourceList: NSManagedObject {}

View file

@ -1,23 +0,0 @@
//
// SourceList+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 7/30/22.
//
//
import CoreData
import Foundation
public extension SourceList {
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceList> {
NSFetchRequest<SourceList>(entityName: "SourceList")
}
@NSManaged var id: UUID
@NSManaged var author: String
@NSManaged var name: String
@NSManaged var urlString: String
}
extension SourceList: Identifiable {}

View file

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>FerriteDB.xcdatamodel</string>
<string>FerriteDB_v2.xcdatamodel</string>
</dict>
</plist>

View file

@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Action" representedClassName="Action" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="deeplink" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentAction" inverseEntity="PluginTag"/>
</entity>
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
<attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="magnetHash" optional="YES" attributeType="String"/>
<attribute name="magnetLink" optional="YES" attributeType="String"/>
<attribute name="orderNum" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seeders" optional="YES" attributeType="String"/>
<attribute name="size" optional="YES" attributeType="String"/>
<attribute name="source" attributeType="String" defaultValueString=""/>
<attribute name="title" optional="YES" attributeType="String"/>
</entity>
<entity name="History" representedClassName="History" syncable="YES">
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="dateString" optional="YES" attributeType="String"/>
<relationship name="entries" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="HistoryEntry" inverseName="parentHistory" inverseEntity="HistoryEntry"/>
</entity>
<entity name="HistoryEntry" representedClassName="HistoryEntry" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="source" optional="YES" attributeType="String"/>
<attribute name="subName" optional="YES" attributeType="String"/>
<attribute name="timeStamp" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="url" optional="YES" attributeType="String"/>
<relationship name="parentHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="History" inverseName="entries" inverseEntity="History"/>
</entity>
<entity name="PluginList" representedClassName="PluginList" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="urlString" attributeType="String" defaultValueString=""/>
</entity>
<entity name="PluginTag" representedClassName="PluginTag" syncable="YES">
<attribute name="colorHex" optional="YES" attributeType="String"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<relationship name="parentAction" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Action" inverseName="tags" inverseEntity="Action"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/>
</entity>
<entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="baseUrl" optional="YES" attributeType="String"/>
<attribute name="dynamicBaseUrl" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
<relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/>
<relationship name="rssParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceRssParser" inverseName="parentSource" inverseEntity="SourceRssParser"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentSource" inverseEntity="PluginTag"/>
</entity>
<entity name="SourceApi" representedClassName="SourceApi" syncable="YES" codeGenerationType="class">
<attribute name="apiUrl" optional="YES" attributeType="String"/>
<relationship name="clientId" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientId" inverseName="parentApi" inverseEntity="SourceApiClientId"/>
<relationship name="clientSecret" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientSecret" inverseName="parentApi" inverseEntity="SourceApiClientSecret"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="api" inverseEntity="Source"/>
</entity>
<entity name="SourceApiClientId" representedClassName="SourceApiClientId" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientId" inverseEntity="SourceApi"/>
</entity>
<entity name="SourceApiClientSecret" representedClassName="SourceApiClientSecret" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientSecret" inverseEntity="SourceApi"/>
</entity>
<entity name="SourceApiCredential" representedClassName="SourceApiCredential" isAbstract="YES" syncable="YES" codeGenerationType="class">
<attribute name="dynamic" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="expiryLength" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="query" optional="YES" attributeType="String"/>
<attribute name="responseType" optional="YES" attributeType="String"/>
<attribute name="timeStamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="urlString" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="String"/>
</entity>
<entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString="text"/>
<attribute name="discriminator" optional="YES" attributeType="String"/>
<attribute name="query" attributeType="String" defaultValueString=""/>
<attribute name="regex" optional="YES" attributeType="String"/>
</entity>
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
<attribute name="rows" attributeType="String" defaultValueString=""/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentHtmlParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceJsonParser" representedClassName="SourceJsonParser" syncable="YES">
<attribute name="results" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="searchUrl" optional="YES" attributeType="String"/>
<attribute name="subResults" optional="YES" attributeType="String"/>
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentJsonParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceMagnetHash" representedClassName="SourceMagnetHash" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetHash" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetHash" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetHash" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceMagnetLink" representedClassName="SourceMagnetLink" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<attribute name="externalLinkQuery" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetLink" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
<attribute name="items" attributeType="String" defaultValueString=""/>
<attribute name="rssUrl" optional="YES" attributeType="String"/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentRssParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceSeedLeech" representedClassName="SourceSeedLeech" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString=""/>
<attribute name="combined" optional="YES" attributeType="String"/>
<attribute name="discriminator" optional="YES" attributeType="String"/>
<attribute name="leecherRegex" optional="YES" attributeType="String"/>
<attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="seederRegex" optional="YES" attributeType="String"/>
<attribute name="seeders" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="seedLeech" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="seedLeech" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="seedLeech" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceSize" representedClassName="SourceSize" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="size" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="size" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="size" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceTitle" representedClassName="SourceTitle" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="title" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="title" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="title" inverseEntity="SourceRssParser"/>
</entity>
</model>

View file

@ -91,7 +91,7 @@ struct PersistenceController {
save()
}
func createBookmark(_ bookmarkJson: BookmarkJson) {
func createBookmark(_ bookmarkJson: BookmarkJson, performSave: Bool) {
let bookmarkRequest = Bookmark.fetchRequest()
bookmarkRequest.predicate = NSPredicate(
format: "source == %@ AND title == %@ AND magnetLink == %@",
@ -113,32 +113,31 @@ struct PersistenceController {
newBookmark.seeders = bookmarkJson.seeders
newBookmark.leechers = bookmarkJson.leechers
save(backgroundContext)
if performSave {
save(backgroundContext)
}
}
func createHistory(_ entryJson: HistoryEntryJson, date: Double? = nil) {
func createHistory(_ entryJson: HistoryEntryJson, performSave: Bool, isBackup: Bool = false, date: Double? = nil) {
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)
var existingHistory: History?
// 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 }
let existingEntries = history.entryArray.filter { $0.url == entryJson.url && $0.name == entryJson.name }
// Maybe add !isBackup here
if !existingEntries.isEmpty {
for entry in existingEntries {
PersistenceController.shared.delete(entry, context: backgroundContext)
if isBackup {
continue
} else {
for entry in existingEntries {
PersistenceController.shared.delete(entry, context: backgroundContext)
}
}
}
@ -148,15 +147,24 @@ struct PersistenceController {
}
}
newHistoryEntry.parentHistory = histories.first ?? History(context: backgroundContext)
} else {
newHistoryEntry.parentHistory = History(context: backgroundContext)
existingHistory = histories.first
}
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
newHistoryEntry.parentHistory = existingHistory ?? History(context: backgroundContext)
newHistoryEntry.parentHistory?.dateString = historyDateString
newHistoryEntry.parentHistory?.date = historyDate
save(backgroundContext)
if performSave {
save(backgroundContext)
}
}
func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? {
@ -200,8 +208,7 @@ struct PersistenceController {
return predicate
}
// Always use the background context to batch delete
// Merge changes into both contexts to update views
// Wrapper to batch delete history objects
func batchDeleteHistory(range: HistoryDeleteRange) throws {
let predicate = getHistoryPredicate(range: range)
@ -213,6 +220,13 @@ struct PersistenceController {
throw HistoryDeleteError.noDate("No history date range was provided and you weren't trying to clear everything! Try relaunching the app?")
}
try batchDelete("History", predicate: predicate)
}
// Always use the background context to batch delete
// Merge changes into both contexts to update views
func batchDelete(_ entity: String, predicate: NSPredicate? = nil) throws {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeObjectIDs

View file

@ -15,7 +15,7 @@ struct FerriteApp: App {
@StateObject var toastModel: ToastViewModel = .init()
@StateObject var debridManager: DebridManager = .init()
@StateObject var navModel: NavigationViewModel = .init()
@StateObject var sourceManager: SourceManager = .init()
@StateObject var pluginManager: PluginManager = .init()
@StateObject var backupManager: BackupManager = .init()
var body: some Scene {
@ -24,7 +24,7 @@ struct FerriteApp: App {
.onAppear {
scrapingModel.toastModel = toastModel
debridManager.toastModel = toastModel
sourceManager.toastModel = toastModel
pluginManager.toastModel = toastModel
backupManager.toastModel = toastModel
navModel.toastModel = toastModel
}
@ -32,7 +32,7 @@ struct FerriteApp: App {
.environmentObject(scrapingModel)
.environmentObject(toastModel)
.environmentObject(navModel)
.environmentObject(sourceManager)
.environmentObject(pluginManager)
.environmentObject(backupManager)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}

View file

@ -0,0 +1,32 @@
//
// ActionModels.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import Foundation
public struct ActionJson: Codable, Hashable, PluginJson {
public let name: String
public let version: Int16
let minVersion: String?
let requires: [ActionRequirement]
let deeplink: String?
public var author: String?
public var listId: UUID?
public var tags: [PluginTagJson]?
}
extension ActionJson {
// Fetches all tags without optional requirement
// Avoids the need for extra tag additions in DB
public func getTags() -> [PluginTagJson] {
return requires.map { PluginTagJson(name: $0.rawValue, colorHex: nil) } + (tags.map { $0 } ?? [])
}
}
public enum ActionRequirement: String, Codable {
case magnet
case debrid
}

View file

@ -12,7 +12,11 @@ public struct Backup: Codable {
var bookmarks: [BookmarkJson]?
var history: [HistoryJson]?
var sourceNames: [String]?
var sourceLists: [SourceListBackupJson]?
var actionNames: [String]?
var pluginListUrls: [String]?
// MARK: Remove once v1 backups are unsupported
var sourceLists: [PluginListBackupJson]?
}
// MARK: - CoreData translation
@ -43,8 +47,8 @@ struct HistoryEntryJson: Codable {
let source: String?
}
// Differs from SourceListJson
struct SourceListBackupJson: Codable {
// Differs from PluginListJson
struct PluginListBackupJson: Codable {
let name: String
let author: String
let id: String

View file

@ -0,0 +1,32 @@
//
// PluginModels.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import Foundation
public struct PluginListJson: Codable {
let name: String
let author: String
var sources: [SourceJson]?
var actions: [ActionJson]?
}
// Color: Hex value
public struct PluginTagJson: Codable, Hashable, Sendable {
public let name: String
public let colorHex: String?
enum CodingKeys: String, CodingKey {
case name
case colorHex = "color"
}
}
extension PluginManager {
enum PluginManagerError: Error {
case ListAddition(description: String)
}
}

View file

@ -12,26 +12,28 @@ public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
case text
}
public struct SourceListJson: Codable, Sendable {
let name: String
let author: String
var sources: [SourceJson]
}
public struct SourceJson: Codable, Hashable, Sendable {
let name: String
let version: Int16
public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
public let name: String
public let version: Int16
let minVersion: String?
let baseUrl: String?
let fallbackUrls: [String]?
var dynamicBaseUrl: Bool?
var author: String?
var listId: UUID?
let trackers: [String]?
let api: SourceApiJson?
let jsonParser: SourceJsonParserJson?
let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson?
public var author: String?
public var listId: UUID?
public var tags: [PluginTagJson]?
}
extension SourceJson {
// Fetches all tags without optional requirement
public func getTags() -> [PluginTagJson] {
return tags ?? []
}
}
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {

View file

@ -0,0 +1,35 @@
//
// Plugin.swift
// Ferrite
//
// Created by Brian Dashore on 1/25/23.
//
import CoreData
import Foundation
public protocol Plugin: ObservableObject, NSManagedObject {
var id: UUID { get set }
var listId: UUID? { get set }
var name: String { get set }
var version: Int16 { get set }
var author: String { get set }
var enabled: Bool { get set }
var tags: NSOrderedSet? { get set }
func getTags() -> [PluginTagJson]
}
extension Plugin {
var tagArray: [PluginTag] {
return self.tags?.array as? [PluginTag] ?? []
}
}
public protocol PluginJson: Hashable {
var name: String { get }
var version: Int16 { get }
var author: String? { get set }
var listId: UUID? { get set }
var tags: [PluginTagJson]? { get set }
func getTags() -> [PluginTagJson]
}

View file

@ -9,18 +9,33 @@ import Foundation
public class BackupManager: ObservableObject {
// Constant variable for backup versions
let latestBackupVersion: Int = 1
let latestBackupVersion: Int = 2
var toastModel: ToastViewModel?
@Published var showRestoreAlert = false
@Published var showRestoreCompletedAlert = false
@Published var restoreCompletedMessage: [String] = []
@Published var backupUrls: [URL] = []
@Published var backupSourceNames: [String] = []
@Published var selectedBackupUrl: URL?
func createBackup() {
@MainActor
func updateRestoreCompletedMessage(newString: String) {
restoreCompletedMessage.append(newString)
}
@MainActor
func toggleRestoreCompletedAlert() {
showRestoreCompletedAlert.toggle()
}
@MainActor
func updateBackupUrls(newUrl: URL) {
backupUrls.append(newUrl)
}
func createBackup() async {
var backup = Backup(version: latestBackupVersion)
let backgroundContext = PersistenceController.shared.backgroundContext
@ -71,16 +86,14 @@ public class BackupManager: ObservableObject {
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
)
}
let actionRequest = Action.fetchRequest()
if let actions = try? backgroundContext.fetch(actionRequest) {
backup.actionNames = actions.map(\.name)
}
let pluginListRequest = PluginList.fetchRequest()
if let pluginLists = try? backgroundContext.fetch(pluginListRequest) {
backup.pluginListUrls = pluginLists.map(\.urlString)
}
do {
@ -94,18 +107,20 @@ public class BackupManager: ObservableObject {
let writeUrl = backupsPath.appendingPathComponent("Ferrite-backup-\(snapshot).feb")
try encodedJson.write(to: writeUrl)
backupUrls.append(writeUrl)
await updateBackupUrls(newUrl: writeUrl)
} catch {
print(error)
await toastModel?.updateToastDescription("Backup error: \(error)")
print("Backup error: \(error)")
}
}
// Backup is in local documents directory, so no need to restore it from the shared URL
func restoreBackup() {
// Pass the pluginManager reference since it's not used throughout the class like toastModel
func restoreBackup(pluginManager: PluginManager, doOverwrite: Bool) async {
guard let backupUrl = selectedBackupUrl else {
Task {
await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.")
}
await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.")
print("Backup restore error: Could not find backup in app directory.")
return
}
@ -113,64 +128,72 @@ public class BackupManager: ObservableObject {
let backgroundContext = PersistenceController.shared.backgroundContext
do {
// Delete all relevant entities to prevent issues with restoration if overwrite is selected
if doOverwrite {
try PersistenceController.shared.batchDelete("Bookmark")
try PersistenceController.shared.batchDelete("History")
try PersistenceController.shared.batchDelete("HistoryEntry")
try PersistenceController.shared.batchDelete("PluginList")
try PersistenceController.shared.batchDelete("Source")
try PersistenceController.shared.batchDelete("Action")
}
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)
PersistenceController.shared.createBookmark(bookmark, performSave: false)
}
}
if let storedHistories = backup.history {
for storedHistory in storedHistories {
for storedEntry in storedHistory.entries {
PersistenceController.shared.createHistory(storedEntry, date: storedHistory.date)
PersistenceController.shared.createHistory(
storedEntry,
performSave: false,
isBackup: true,
date: storedHistory.date
)
}
}
}
if let storedLists = backup.sourceLists {
if let storedLists = backup.sourceLists, (backup.version == 1) {
// Only present in v1 backups
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
try await pluginManager.addPluginList(list.urlString, existingPluginList: nil)
}
} else if let pluginListUrls = backup.pluginListUrls {
// v2 and up
for listUrl in pluginListUrls {
try await pluginManager.addPluginList(listUrl, existingPluginList: nil)
}
}
backupSourceNames = backup.sourceNames ?? []
if let sourceNames = backup.sourceNames {
await updateRestoreCompletedMessage(newString: sourceNames.isEmpty ? "No sources need to be reinstalled" : "Reinstall sources: \(sourceNames.joined(separator: ", "))")
}
if let actionNames = backup.actionNames {
await updateRestoreCompletedMessage(newString: actionNames.isEmpty ? "No actions need to be reinstalled" : "Reinstall actions: \(actionNames.joined(separator: ", "))")
}
PersistenceController.shared.save(backgroundContext)
// if iOS 14 is available, sleep to prevent any issues with alerts
if #available(iOS 15, *) {
showRestoreCompletedAlert.toggle()
await toggleRestoreCompletedAlert()
} else {
Task {
try? await Task.sleep(seconds: 0.1)
try? await Task.sleep(seconds: 0.1)
Task { @MainActor in
showRestoreCompletedAlert.toggle()
}
}
await toggleRestoreCompletedAlert()
}
} catch {
Task {
await toastModel?.updateToastDescription("Backup restore: \(error)")
}
await toastModel?.updateToastDescription("Backup restore error: \(error)")
print("Backup restore error: \(error)")
}
}
@ -187,7 +210,8 @@ public class BackupManager: ObservableObject {
}
} catch {
Task {
await toastModel?.updateToastDescription("Backup removal: \(error)")
await toastModel?.updateToastDescription("Backup removal error: \(error)")
print("Backup removal error: \(error)")
}
}
}

View file

@ -9,7 +9,7 @@ import SwiftUI
enum ViewTab {
case search
case sources
case plugins
case settings
case library
}
@ -56,7 +56,6 @@ class NavigationViewModel: ObservableObject {
var selectedSource: Source?
@Published var showSourceListEditor: Bool = false
var selectedSourceList: SourceList?
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none

View file

@ -5,27 +5,28 @@
// Created by Brian Dashore on 7/25/22.
//
import CoreData
import Foundation
import SwiftUI
public class SourceManager: ObservableObject {
public class PluginManager: ObservableObject {
var toastModel: ToastViewModel?
@Published var availableSources: [SourceJson] = []
var urlErrorAlertText = ""
@Published var showUrlErrorAlert = false
@Published var availableActions: [ActionJson] = []
@MainActor
public func fetchSourcesFromUrl() async {
let sourceListRequest = SourceList.fetchRequest()
public func fetchPluginsFromUrl() async {
let pluginListRequest = PluginList.fetchRequest()
do {
let sourceLists = try PersistenceController.shared.backgroundContext.fetch(sourceListRequest)
var tempAvailableSources: [SourceJson] = []
let pluginLists = try PersistenceController.shared.backgroundContext.fetch(pluginListRequest)
for sourceList in sourceLists {
guard let url = URL(string: sourceList.urlString) else {
if pluginLists.isEmpty {
availableSources = []
availableActions = []
}
for pluginList in pluginLists {
guard let url = URL(string: pluginList.urlString) else {
return
}
@ -33,39 +34,107 @@ public class SourceManager: ObservableObject {
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
let (data, _) = try await URLSession.shared.data(for: request)
let sourceResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
for var source in sourceResponse.sources {
// If there is a minVersion, check and see if the source is valid
if checkAppVersion(minVersion: source.minVersion) {
source.author = sourceList.author
source.listId = sourceList.id
if let sources = pluginResponse.sources {
// Faster and more performant to map instead of a for loop
availableSources = sources.compactMap { inputJson in
if checkAppVersion(minVersion: inputJson.minVersion) {
return SourceJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
baseUrl: inputJson.baseUrl,
fallbackUrls: inputJson.fallbackUrls,
trackers: inputJson.trackers,
api: inputJson.api,
jsonParser: inputJson.jsonParser,
rssParser: inputJson.rssParser,
htmlParser: inputJson.htmlParser,
author: pluginList.author,
listId: pluginList.id,
tags: inputJson.tags
)
} else {
return nil
}
}
}
tempAvailableSources.append(source)
if let actions = pluginResponse.actions {
availableActions = actions.compactMap { inputJson in
if checkAppVersion(minVersion: inputJson.minVersion) {
return ActionJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
requires: inputJson.requires,
deeplink: inputJson.deeplink,
author: pluginList.author,
listId: pluginList.id,
tags: inputJson.tags
)
} else {
return nil
}
}
}
}
availableSources = tempAvailableSources
} catch {
print(error)
toastModel?.updateToastDescription("Plugin fetch error: \(error)")
print("Plugin fetch error: \(error)")
}
}
func fetchUpdatedSources(installedSources: FetchedResults<Source>) -> [SourceJson] {
var updatedSources: [SourceJson] = []
// Check if underlying type is Source or Action
func fetchFilteredPlugins<P: Plugin, PJ: PluginJson>(installedPlugins: FetchedResults<P>, searchText: String) -> [PJ] {
let availablePlugins: [PJ] = fetchCastedPlugins(PJ.self)
for source in installedSources {
if let availableSource = availableSources.first(where: {
source.listId == $0.listId && source.name == $0.name && source.author == $0.author
return availablePlugins
.filter { availablePlugin in
let pluginExists = installedPlugins.contains(where: {
availablePlugin.name == $0.name &&
availablePlugin.listId == $0.listId &&
availablePlugin.author == $0.author
})
if searchText.isEmpty {
return !pluginExists
} else {
return !pluginExists && availablePlugin.name.lowercased().contains(searchText.lowercased())
}
}
}
func fetchUpdatedPlugins<P: Plugin, PJ: PluginJson>(installedPlugins: FetchedResults<P>, searchText: String) -> [PJ] {
var updatedPlugins: [PJ] = []
let availablePlugins: [PJ] = fetchCastedPlugins(PJ.self)
for plugin in installedPlugins {
if let availablePlugin = availablePlugins.first(where: {
plugin.listId == $0.listId && plugin.name == $0.name && plugin.author == $0.author
}),
availableSource.version > source.version
availablePlugin.version > plugin.version
{
updatedSources.append(availableSource)
updatedPlugins.append(availablePlugin)
}
}
return updatedSources
return updatedPlugins
.filter {
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
}
}
func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
switch String(describing: PJ.self) {
case "SourceJson":
return availableSources as? [PJ] ?? []
case "ActionJson":
return availableActions as? [PJ] ?? []
default:
return []
}
}
// Checks if the current app version is supported by the source
@ -89,7 +158,98 @@ public class SourceManager: ObservableObject {
}
}
public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) async {
// The iOS version of Ferrite only runs deeplink actions
@MainActor
public func runDeeplinkAction(_ action: Action, urlString: String?) {
guard let deeplink = action.deeplink, let urlString else {
toastModel?.updateToastDescription("Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!")
print("Could not run action: \(action.name) since there is no deeplink to execute.")
return
}
let playbackUrl = URL(string: deeplink.replacingOccurrences(of: "{link}", with: urlString))
if let playbackUrl {
UIApplication.shared.open(playbackUrl)
} else {
toastModel?.updateToastDescription("Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!")
print("Could not run action: \(action.name) because the created deeplink (\(String(describing: playbackUrl))) was invalid")
}
}
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
guard let actionJson else {
await toastModel?.updateToastDescription("Action addition error: No action present. Contact the app dev!")
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
if actionJson.requires.count < 1 {
await toastModel?.updateToastDescription("Action addition error: actions must require an input. Please contact the action dev!")
print("Action name \(actionJson.name) does not have a requires parameter")
return
}
guard let deeplink = actionJson.deeplink else {
await toastModel?.updateToastDescription("Action addition error: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!")
print("Action name \(actionJson.name) did not have a deeplink")
return
}
let existingActionRequest = Action.fetchRequest()
existingActionRequest.predicate = NSPredicate(format: "name == %@", actionJson.name)
existingActionRequest.fetchLimit = 1
if let existingAction = try? backgroundContext.fetch(existingActionRequest).first {
if doUpsert {
PersistenceController.shared.delete(existingAction, context: backgroundContext)
} else {
await toastModel?.updateToastDescription("Could not install action with name \(actionJson.name) because it is already installed")
print("Action name \(actionJson.name) already exists in user's DB")
return
}
}
let newAction = Action(context: backgroundContext)
newAction.id = UUID()
newAction.name = actionJson.name
newAction.version = actionJson.version
newAction.author = actionJson.author ?? "Unknown"
newAction.listId = actionJson.listId
newAction.requires = actionJson.requires.map { $0.rawValue }
newAction.enabled = true
if let jsonTags = actionJson.tags {
for tag in jsonTags {
let newTag = PluginTag(context: backgroundContext)
newTag.name = tag.name
newTag.colorHex = tag.colorHex
newTag.parentAction = newAction
}
}
newAction.deeplink = deeplink
do {
try backgroundContext.save()
} catch {
await toastModel?.updateToastDescription("Action addition error: \(error)")
print("Action addition error: \(error)")
}
}
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
guard let sourceJson else {
await toastModel?.updateToastDescription("Source addition error: No source present. Contact the app dev!")
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur
@ -97,8 +257,8 @@ public class SourceManager: ObservableObject {
if !dynamicBaseUrl, sourceJson.baseUrl == nil {
await toastModel?.updateToastDescription("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
print("Not adding this source because base URL parameters are malformed")
print("Not adding source \(sourceJson.name) because base URL parameters are malformed")
return
}
@ -112,6 +272,8 @@ public class SourceManager: ObservableObject {
PersistenceController.shared.delete(existingSource, context: backgroundContext)
} else {
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
print("Source name \(sourceJson.name) already exists")
return
}
}
@ -127,6 +289,16 @@ public class SourceManager: ObservableObject {
newSource.listId = sourceJson.listId
newSource.trackers = sourceJson.trackers
if let jsonTags = sourceJson.tags {
for tag in jsonTags {
let newTag = PluginTag(context: backgroundContext)
newTag.name = tag.name
newTag.colorHex = tag.colorHex
newTag.parentSource = newSource
}
}
if let sourceApiJson = sourceJson.api {
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
}
@ -159,7 +331,8 @@ public class SourceManager: ObservableObject {
do {
try backgroundContext.save()
} catch {
await toastModel?.updateToastDescription(error.localizedDescription)
await toastModel?.updateToastDescription("Source addition error: \(error)")
print("Source addition error: \(error)")
}
}
@ -370,57 +543,44 @@ public class SourceManager: ObservableObject {
newSource.htmlParser = newSourceHtmlParser
}
@MainActor
public func addSourceList(sourceUrl: String, existingSourceList: SourceList?) async -> Bool {
// Adds a plugin list
// Can move this to PersistenceController if needed
public func addPluginList(_ url: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
let backgroundContext = PersistenceController.shared.backgroundContext
if sourceUrl.isEmpty || URL(string: sourceUrl) == nil {
urlErrorAlertText = "The provided source list is invalid. Please check if the URL is formatted properly."
showUrlErrorAlert.toggle()
return false
if url.isEmpty || URL(string: url) == nil {
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
}
do {
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!))
let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: url)!))
let rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
if let existingSourceList {
existingSourceList.urlString = sourceUrl
existingSourceList.name = rawResponse.name
existingSourceList.author = rawResponse.author
if let existingPluginList {
existingPluginList.urlString = url
existingPluginList.name = rawResponse.name
existingPluginList.author = rawResponse.author
try PersistenceController.shared.container.viewContext.save()
} else {
let sourceListRequest = SourceList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", sourceUrl)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
sourceListRequest.fetchLimit = 1
try PersistenceController.shared.container.viewContext.save()
} else {
let pluginListRequest = PluginList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", url)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
pluginListRequest.fetchLimit = 1
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
urlErrorAlertText = "An existing source with this information was found. Please try editing the source list instead."
showUrlErrorAlert.toggle()
return false
}
let newSourceUrl = SourceList(context: backgroundContext)
newSourceUrl.id = UUID()
newSourceUrl.urlString = sourceUrl
newSourceUrl.name = rawResponse.name
newSourceUrl.author = rawResponse.author
try backgroundContext.save()
if let existingPluginList = try? backgroundContext.fetch(pluginListRequest).first, !isSheet {
PersistenceController.shared.delete(existingPluginList, context: backgroundContext)
} else if isSheet {
throw PluginManagerError.ListAddition(description: "An existing plugin list with this information was found. Please try editing an existing plugin list instead.")
}
return true
} catch {
print(error)
urlErrorAlertText = error.localizedDescription
showUrlErrorAlert.toggle()
let newPluginList = PluginList(context: backgroundContext)
newPluginList.id = UUID()
newPluginList.urlString = url
newPluginList.name = rawResponse.name
newPluginList.author = rawResponse.author
return false
try backgroundContext.save()
}
}
}

View file

@ -24,7 +24,7 @@ struct DynamicFetchRequest<T: NSManagedObject, Content: View>: View {
sortDescriptors: [NSSortDescriptor] = [],
@ViewBuilder content: @escaping (FetchedResults<T>) -> Content)
{
_fetchRequest = FetchRequest<T>(sortDescriptors: sortDescriptors, predicate: predicate)
_fetchRequest = FetchRequest<T>(entity: T.entity(), sortDescriptors: sortDescriptors, predicate: predicate)
self.content = content
}
}

View file

@ -11,19 +11,16 @@
import SwiftUI
struct NavView<Content: View>: View {
let content: () -> Content
init(@ViewBuilder _ content: @escaping () -> Content) {
self.content = content
}
@ViewBuilder var content: Content
var body: some View {
if #available(iOS 16, *) {
NavigationStack {
content()
content
}
} else {
NavigationView {
content()
content
}
.navigationViewStyle(.stack)
}

View file

@ -0,0 +1,28 @@
//
// Tag.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
import SwiftUI
struct Tag: View {
let name: String
let color: Color?
var horizontalPadding: CGFloat = 7
var verticalPadding: CGFloat = 4
var body: some View {
Text(name.capitalizingFirstLetter())
.font(.caption)
.opacity(0.8)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.background(
RoundedRectangle(cornerRadius: 5)
.foregroundColor(color.map { $0 } ?? .tertiaryLabel)
.opacity(0.3)
)
}
}

View file

@ -15,31 +15,31 @@ struct DebridLabelView: View {
var body: some View {
if let selectedDebridType = debridManager.selectedDebridType {
Text(selectedDebridType.toString(abbreviated: true))
.fontWeight(.bold)
.padding(2)
.background {
Group {
if let magnet, cloudLinks.isEmpty {
switch debridManager.matchMagnetHash(magnet) {
case .full:
Color.green
case .partial:
Color.orange
case .none:
Color.red
}
} else if cloudLinks.count == 1 {
Color.green
} else if cloudLinks.count > 1 {
Color.orange
} else {
Color.red
}
}
.cornerRadius(4)
.opacity(0.5)
}
Tag(
name: selectedDebridType.toString(abbreviated: true),
color: getTagColor(),
horizontalPadding: 5,
verticalPadding: 3
)
}
}
func getTagColor() -> Color {
if let magnet, cloudLinks.isEmpty {
switch debridManager.matchMagnetHash(magnet) {
case .full:
return Color.green
case .partial:
return Color.orange
case .none:
return Color.red
}
} else if cloudLinks.count == 1 {
return Color.green
} else if cloudLinks.count > 1 {
return Color.orange
} else {
return Color.red
}
}
}

View file

@ -37,7 +37,7 @@ struct AllDebridCloudView: View {
if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo)
PersistenceController.shared.createHistory(historyInfo, performSave: true)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
}
}

View file

@ -34,7 +34,8 @@ struct PremiumizeCloudView: View {
name: item.name,
url: debridManager.downloadUrl,
source: DebridType.premiumize.toString()
)
),
performSave: true
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)

View file

@ -31,7 +31,8 @@ struct RealDebridCloudView: View {
name: downloadResponse.filename,
url: downloadResponse.download,
source: DebridType.realDebrid.toString()
)
),
performSave: true
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
@ -69,7 +70,7 @@ struct RealDebridCloudView: View {
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: torrentLink)
if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo)
PersistenceController.shared.createHistory(historyInfo, performSave: true)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
}

View file

@ -77,4 +77,12 @@ struct HistoryButtonView: View {
.backport.tint(.primary)
.disableInteraction(navModel.currentChoiceSheet != nil)
}
func getTagColor() -> Color {
if let url = entry.url, url.starts(with: "https://") {
return Color.green
} else {
return Color.red
}
}
}

View file

@ -7,52 +7,60 @@
import SwiftUI
struct InstalledSourceButtonView: View {
struct InstalledPluginButtonView<P: Plugin>: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@ObservedObject var installedSource: Source
@ObservedObject var installedPlugin: P
var body: some View {
Toggle(isOn: Binding<Bool>(
get: { installedSource.enabled },
get: { installedPlugin.enabled },
set: {
installedSource.enabled = $0
installedPlugin.enabled = $0
PersistenceController.shared.save()
}
)) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(installedSource.name)
Text("v\(installedSource.version)")
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(installedPlugin.name)
Text("v\(installedPlugin.version)")
.foregroundColor(.secondary)
}
Text("by \(installedPlugin.author)")
.foregroundColor(.secondary)
}
Text("by \(installedSource.author)")
.foregroundColor(.secondary)
if let tags = installedPlugin.getTags(), !tags.isEmpty {
PluginTagsView(tags: tags)
}
}
.padding(.vertical, 2)
}
.contextMenu {
Button {
navModel.selectedSource = installedSource
navModel.showSourceSettings.toggle()
} label: {
Text("Settings")
Image(systemName: "gear")
if let installedSource = installedPlugin as? Source {
Button {
navModel.selectedSource = installedSource
navModel.showSourceSettings.toggle()
} label: {
Text("Settings")
Image(systemName: "gear")
}
}
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(installedSource, context: backgroundContext)
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(installedSource, context: backgroundContext)
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")

View file

@ -0,0 +1,51 @@
//
// SourceCatalogButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 8/5/22.
//
import SwiftUI
struct PluginCatalogButtonView<PJ: PluginJson>: View {
@EnvironmentObject var pluginManager: PluginManager
let availablePlugin: PJ
let doUpsert: Bool
var body: some View {
HStack {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(availablePlugin.name)
Text("v\(availablePlugin.version)")
.foregroundColor(.secondary)
}
Text("by \(availablePlugin.author ?? "No author")")
.foregroundColor(.secondary)
}
if let tags = availablePlugin.getTags(), !tags.isEmpty {
PluginTagsView(tags: tags)
}
}
Spacer()
Button("Install") {
Task {
if let availableSource = availablePlugin as? SourceJson {
await pluginManager.installSource(sourceJson: availableSource, doUpsert: doUpsert)
} else if let availableAction = availablePlugin as? ActionJson {
await pluginManager.installAction(actionJson: availableAction, doUpsert: doUpsert)
} else {
return
}
}
}
}
.padding(.vertical, 2)
}
}

View file

@ -8,7 +8,7 @@
import SwiftUI
struct SourceCatalogButtonView: View {
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
let availableSource: SourceJson
@ -29,7 +29,7 @@ struct SourceCatalogButtonView: View {
Button("Install") {
Task {
await sourceManager.installSource(sourceJson: availableSource)
await pluginManager.installSource(sourceJson: availableSource)
}
}
}

View file

@ -0,0 +1,80 @@
//
// SourceListView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import SwiftUI
struct PluginListView<P: Plugin, PJ: PluginJson>: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@Binding var searchText: String
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var filteredUpdatedPlugins: [PJ] = []
@State private var filteredAvailablePlugins: [PJ] = []
@State private var sourcePredicate: NSPredicate?
var body: some View {
DynamicFetchRequest(predicate: sourcePredicate) { (installedPlugins: FetchedResults<P>) in
List {
if !filteredUpdatedPlugins.isEmpty {
Section(header: InlineHeader("Updates")) {
ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
PluginCatalogButtonView(availablePlugin: updatedPlugin, doUpsert: true)
}
}
}
if !installedPlugins.isEmpty {
Section(header: InlineHeader("Installed")) {
ForEach(installedPlugins, id: \.self) { source in
InstalledPluginButtonView(installedPlugin: source)
}
}
}
if !filteredAvailablePlugins.isEmpty {
Section(header: InlineHeader("Catalog")) {
ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
if !installedPlugins.contains(where: {
availablePlugin.name == $0.name &&
availablePlugin.listId == $0.listId &&
availablePlugin.author == $0.author
}) {
PluginCatalogButtonView(availablePlugin: availablePlugin, doUpsert: false)
}
}
}
}
}
.listStyle(.insetGrouped)
.sheet(isPresented: $navModel.showSourceSettings) {
if String(describing: P.self) == "Source" {
SourceSettingsView()
.environmentObject(navModel)
}
}
.onAppear {
filteredAvailablePlugins = pluginManager.fetchFilteredPlugins(installedPlugins: installedPlugins, searchText: searchText)
filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins(installedPlugins: installedPlugins, searchText: searchText)
}
.onChange(of: searchText) { _ in
sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText)
}
.onReceive(installedPlugins.publisher.count()) { _ in
filteredAvailablePlugins = pluginManager.fetchFilteredPlugins(installedPlugins: installedPlugins, searchText: searchText)
filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins(installedPlugins: installedPlugins, searchText: searchText)
}
}
}
}

View file

@ -0,0 +1,22 @@
//
// PluginTagView.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
import SwiftUI
struct PluginTagsView: View {
let tags: [PluginTagJson]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(tags, id: \.self) { tag in
Tag(name: tag.name, color: tag.colorHex.map { Color(hexadecimal: $0) })
}
}
}
}
}

View file

@ -17,28 +17,34 @@ struct SourceSettingsView: View {
List {
if let selectedSource = navModel.selectedSource {
Section(header: InlineHeader("Info")) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(selectedSource.name)
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(selectedSource.name)
Text("v\(selectedSource.version)")
.foregroundColor(.secondary)
}
Text("by \(selectedSource.author)")
.foregroundColor(.secondary)
Group {
Text("ID: \(selectedSource.id)")
if let listId = selectedSource.listId {
Text("List ID: \(listId)")
} else {
Text("No list ID found. This source should be removed.")
Text("v\(selectedSource.version)")
.foregroundColor(.secondary)
}
Text("by \(selectedSource.author)")
.foregroundColor(.secondary)
Group {
Text("ID: \(selectedSource.id)")
if let listId = selectedSource.listId {
Text("List ID: \(listId)")
} else {
Text("No list ID found. This source should be removed.")
}
}
.foregroundColor(.secondary)
.font(.caption)
}
if let tags = selectedSource.getTags(), !tags.isEmpty {
PluginTagsView(tags: tags)
}
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 2)
}

View file

@ -38,7 +38,8 @@ struct SearchResultButtonView: View {
name: result.title,
url: debridManager.downloadUrl,
source: result.source
)
),
performSave: true
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
@ -63,7 +64,8 @@ struct SearchResultButtonView: View {
name: result.title,
url: result.magnet.link,
source: result.source
)
),
performSave: true
)
navModel.runMagnetAction(magnet: result.magnet)

View file

@ -57,7 +57,9 @@ struct BackupsView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
backupManager.createBackup()
Task {
await backupManager.createBackup()
}
} label: {
Image(systemName: "plus")
}

View file

@ -7,37 +7,41 @@
import SwiftUI
struct SourceListEditorView: View {
struct PluginListEditorView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
let backgroundContext = PersistenceController.shared.backgroundContext
@State private var sourceUrlSet = false
@State var selectedPluginList: PluginList?
@State private var sourceUrl: String = ""
@State private var sourceUrlSet = false
@State private var showUrlErrorAlert = false
@State private var pluginListUrl: String = ""
@State private var urlErrorAlertText: String = ""
var body: some View {
NavView {
Form {
TextField("Enter URL", text: $sourceUrl)
TextField("Enter URL", text: $pluginListUrl)
.disableAutocorrection(true)
.keyboardType(.URL)
.autocapitalization(.none)
.conditionalId(sourceUrlSet)
}
.onAppear {
sourceUrl = navModel.selectedSourceList?.urlString ?? ""
pluginListUrl = selectedPluginList?.urlString ?? ""
sourceUrlSet = true
}
.backport.alert(
isPresented: $sourceManager.showUrlErrorAlert,
isPresented: $showUrlErrorAlert,
title: "Error",
message: sourceManager.urlErrorAlertText
message: urlErrorAlertText
)
.navigationTitle("Editing source list")
.navigationTitle("Editing Plugin List")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@ -49,25 +53,23 @@ struct SourceListEditorView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
Task {
if await sourceManager.addSourceList(
sourceUrl: sourceUrl,
existingSourceList: navModel.selectedSourceList
) {
do {
try await pluginManager.addPluginList(pluginListUrl, existingPluginList: selectedPluginList)
presentationMode.wrappedValue.dismiss()
} catch {
urlErrorAlertText = error.localizedDescription
showUrlErrorAlert.toggle()
}
}
}
}
}
.onDisappear {
navModel.selectedSourceList = nil
}
}
}
}
struct SourceListEditorView_Previews: PreviewProvider {
struct PluginListEditorView_Previews: PreviewProvider {
static var previews: some View {
SourceListEditorView()
PluginListEditorView()
}
}

View file

@ -7,40 +7,40 @@
import SwiftUI
struct SettingsSourceListView: View {
struct SettingsPluginListView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: SourceList.entity(),
entity: PluginList.entity(),
sortDescriptors: []
) var sourceLists: FetchedResults<SourceList>
) var pluginLists: FetchedResults<PluginList>
@State private var presentSourceSheet = false
@State private var selectedSourceList: SourceList?
@State private var selectedPluginList: PluginList?
var body: some View {
ZStack {
if sourceLists.isEmpty {
if pluginLists.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
ForEach(pluginLists, id: \.self) { pluginList in
VStack(alignment: .leading, spacing: 5) {
Text(sourceList.name)
Text(pluginList.name)
Text(sourceList.author)
Text(pluginList.author)
.foregroundColor(.gray)
Text("ID: \(sourceList.id)")
Text("ID: \(pluginList.id)")
.font(.caption)
.foregroundColor(.gray)
}
.padding(.vertical, 2)
.contextMenu {
Button {
navModel.selectedSourceList = sourceList
selectedPluginList = pluginList
presentSourceSheet.toggle()
} label: {
Text("Edit")
@ -49,14 +49,14 @@ struct SettingsSourceListView: View {
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(sourceList, context: backgroundContext)
PersistenceController.shared.delete(pluginList, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(sourceList, context: backgroundContext)
PersistenceController.shared.delete(pluginList, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
@ -66,7 +66,7 @@ struct SettingsSourceListView: View {
}
.onDelete { offsets in
for index in offsets {
if let list = sourceLists[safe: index] {
if let list = pluginLists[safe: index] {
PersistenceController.shared.delete(list, context: backgroundContext)
}
}
@ -78,13 +78,13 @@ struct SettingsSourceListView: View {
}
.sheet(isPresented: $presentSourceSheet) {
if #available(iOS 16, *) {
SourceListEditorView()
PluginListEditorView(selectedPluginList: selectedPluginList)
.presentationDetents([.medium])
} else {
SourceListEditorView()
PluginListEditorView(selectedPluginList: selectedPluginList)
}
}
.navigationTitle("Source Lists")
.navigationTitle("Plugin Lists")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@ -98,8 +98,8 @@ struct SettingsSourceListView: View {
}
}
struct SettingsSourceListView_Previews: PreviewProvider {
struct SettingsPluginListView_Previews: PreviewProvider {
static var previews: some View {
SettingsSourceListView()
SettingsPluginListView()
}
}

View file

@ -1,38 +0,0 @@
//
// SourceUpdateButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 8/5/22.
//
import SwiftUI
struct SourceUpdateButtonView: View {
@EnvironmentObject var sourceManager: SourceManager
let updatedSource: SourceJson
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(updatedSource.name)
Text("v\(updatedSource.version)")
.foregroundColor(.secondary)
}
Text("by \(updatedSource.author ?? "Unknown")")
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
Spacer()
Button("Update") {
Task {
await sourceManager.installSource(sourceJson: updatedSource, doUpsert: true)
}
}
}
}
}

View file

@ -12,7 +12,7 @@ struct ContentView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
@FetchRequest(
entity: Source.entity(),
@ -87,7 +87,7 @@ struct ContentView: View {
navModel.isSearching = true
navModel.showSearchProgress = true
let sources = sourceManager.fetchInstalledSources()
let sources = pluginManager.fetchInstalledSources()
await scrapingModel.scanSources(sources: sources)
if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty {

View file

@ -14,6 +14,7 @@ struct MainView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var backupManager: BackupManager
@EnvironmentObject var pluginManager: PluginManager
@AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true
@ -36,11 +37,11 @@ struct MainView: View {
}
.tag(ViewTab.library)
SourcesView()
PluginsView()
.tabItem {
Label("Sources", systemImage: "doc.text")
Label("Plugins", systemImage: "doc.text")
}
.tag(ViewTab.sources)
.tag(ViewTab.plugins)
SettingsView()
.tabItem {
@ -51,10 +52,12 @@ struct MainView: View {
.sheet(item: $navModel.currentChoiceSheet) { item in
switch item {
case .magnet:
MagnetChoiceView()
ActionChoiceView()
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(navModel)
.environmentObject(pluginManager)
.environment(\.managedObjectContext, PersistenceController.shared.container.viewContext)
case .batch:
BatchChoiceView()
.environmentObject(debridManager)
@ -101,30 +104,42 @@ struct MainView: View {
backupManager.showRestoreAlert.toggle()
}
}
// Global alerts for backups
.backport.alert(
// Global alerts and dialogs for backups
.backport.confirmationDialog(
isPresented: $backupManager.showRestoreAlert,
title: "Restore backup?",
message: "Restoring this backup will merge all your data!",
message:
"Merge (preferred): Will merge your current data with the backup \n\n" +
"Overwrite: Will delete and replace all your data \n\n" +
"If Merge causes app instability, uninstall Ferrite and use the Overwrite option.",
buttons: [
.init("Restore", role: .destructive) {
backupManager.restoreBackup()
.init("Merge", role: .destructive) {
Task {
await backupManager.restoreBackup(pluginManager: pluginManager, doOverwrite: false)
}
},
.init(role: .cancel)
.init("Overwrite", role: .destructive) {
Task {
await backupManager.restoreBackup(pluginManager: pluginManager, doOverwrite: true)
}
}
]
)
.backport.alert(
isPresented: $backupManager.showRestoreCompletedAlert,
title: "Backup restored",
message: backupManager.backupSourceNames.isEmpty ?
"No sources need to be reinstalled" :
"Reinstall sources: \(backupManager.backupSourceNames.joined(separator: ", "))"
message: backupManager.restoreCompletedMessage.joined(separator: " \n\n"),
buttons: [
.init("OK") {
backupManager.restoreCompletedMessage = []
}
]
)
// Updater alert
.backport.alert(
isPresented: $showUpdateAlert,
title: "Update available",
message: "Ferrite \(releaseVersionString) can be downloaded. \n\n This alert can be disabled in Settings.",
message: "Ferrite \(releaseVersionString) can be downloaded. \n\nThis alert can be disabled in Settings.",
buttons: [
.init("Download") {
guard let releaseUrl = URL(string: releaseUrlString) else {

View file

@ -0,0 +1,111 @@
//
// PluginsView.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import SwiftUI
import SwiftUIX
struct PluginsView: View {
enum PluginPickerSegment {
case sources
case actions
}
@EnvironmentObject var pluginManager: PluginManager
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
@FetchRequest(
entity: Action.entity(),
sortDescriptors: []
) var actions: FetchedResults<Action>
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var selectedSegment: PluginPickerSegment = .sources
@State private var checkedForPlugins = false
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var searchText: String = ""
@State private var viewTask: Task<Void, Never>?
var body: some View {
NavView {
VStack {
Picker("Segments", selection: $selectedSegment) {
Text("Sources").tag(PluginPickerSegment.sources)
Text("Actions").tag(PluginPickerSegment.actions)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 5)
if checkedForPlugins {
switch selectedSegment {
case .sources:
PluginListView<Source, SourceJson>(searchText: $searchText)
case .actions:
PluginListView<Action, ActionJson>(searchText: $searchText)
}
}
Spacer()
}
.overlay {
if checkedForPlugins {
switch selectedSegment {
case .sources:
if sources.isEmpty && pluginManager.availableSources.isEmpty {
EmptyInstructionView(title: "No Sources", message: "Add a plugin list in Settings")
}
case .actions:
if actions.isEmpty && pluginManager.availableActions.isEmpty {
EmptyInstructionView(title: "No Actions", message: "Add a plugin list in Settings")
}
}
} else {
ProgressView()
}
}
.onAppear {
viewTask = Task {
await pluginManager.fetchPluginsFromUrl()
checkedForPlugins = true
}
}
.onDisappear {
viewTask?.cancel()
checkedForPlugins = false
}
.navigationTitle("Plugins")
.navigationSearchBar {
SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
}
}
}
struct PluginsView_Previews: PreviewProvider {
static var previews: some View {
PluginsView()
}
}

View file

@ -11,7 +11,7 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
let backgroundContext = PersistenceController.shared.backgroundContext
@ -84,8 +84,8 @@ struct SettingsView: View {
}
}
Section(header: Text("Source management")) {
NavigationLink("Source lists", destination: SettingsSourceListView())
Section(header: Text("Plugin management")) {
NavigationLink("Plugin lists", destination: SettingsPluginListView())
}
Section(header: Text("Default actions")) {

View file

@ -8,14 +8,18 @@
import SwiftUI
import SwiftUIX
struct MagnetChoiceView: View {
struct ActionChoiceView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@FetchRequest(
entity: Action.entity(),
sortDescriptors: []
) var actions: FetchedResults<Action>
@State private var showLinkCopyAlert = false
@State private var showMagnetCopyAlert = false
@ -39,16 +43,12 @@ struct MagnetChoiceView: View {
if !debridManager.downloadUrl.isEmpty {
Section(header: "Debrid options") {
ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.downloadUrl, .outplayer)
}
ListRowButtonView("Play on VLC", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.downloadUrl, .vlc)
}
ListRowButtonView("Play on Infuse", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.downloadUrl, .infuse)
ForEach(actions, id: \.id) { action in
if action.requires.contains(ActionRequirement.debrid.rawValue) {
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
pluginManager.runDeeplinkAction(action, urlString: debridManager.downloadUrl)
}
}
}
ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") {
@ -73,6 +73,14 @@ struct MagnetChoiceView: View {
if !navModel.resultFromCloud {
Section(header: "Magnet options") {
ForEach(actions, id: \.id) { action in
if action.requires.contains(ActionRequirement.magnet.rawValue) {
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
pluginManager.runDeeplinkAction(action, urlString: navModel.selectedMagnet?.link)
}
}
}
ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = navModel.selectedMagnet?.link
showMagnetCopyAlert.toggle()
@ -92,10 +100,6 @@ struct MagnetChoiceView: View {
navModel.showLocalActivitySheet.toggle()
}
}
ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") {
navModel.runMagnetAction(magnet: navModel.selectedMagnet, .webtor)
}
}
}
}
@ -131,8 +135,8 @@ struct MagnetChoiceView: View {
}
}
struct MagnetChoiceView_Previews: PreviewProvider {
struct ActionChoiceView_Previews: PreviewProvider {
static var previews: some View {
MagnetChoiceView()
ActionChoiceView()
}
}

View file

@ -79,7 +79,7 @@ struct BatchChoiceView: View {
if var selectedHistoryInfo = navModel.selectedHistoryInfo {
selectedHistoryInfo.url = debridManager.downloadUrl
selectedHistoryInfo.subName = fileName
PersistenceController.shared.createHistory(selectedHistoryInfo)
PersistenceController.shared.createHistory(selectedHistoryInfo, performSave: true)
}
navModel.runDebridAction(urlString: debridManager.downloadUrl)

View file

@ -1,142 +0,0 @@
//
// SourceListView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import Introspect
import SwiftUI
import SwiftUIX
struct SourcesView: View {
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var checkedForSources = false
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var viewTask: Task<Void, Never>? = 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 {
DynamicFetchRequest(predicate: sourcePredicate) { (installedSources: FetchedResults<Source>) in
ZStack {
if !checkedForSources {
ProgressView()
} else if installedSources.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)
}
}
.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
}
}
.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
})
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: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
}
}
}
}
struct SourcesView_Previews: PreviewProvider {
static var previews: some View {
SourcesView()
}
}