diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1cb3eb7..b429522 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -8,7 +8,7 @@ jobs: build: runs-on: macos-12 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: @@ -25,7 +25,7 @@ jobs: cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload - name: Upload artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1bc654d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Build and upload release ipa + +on: + release: + types: + - created + +jobs: + build: + runs-on: macos-12 + steps: + - uses: actions/checkout@v3 + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest + - name: Build + run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO + env: + IS_NIGHTLY: NO + - name: Get app version + run: | + echo "app_version=$(/usr/libexec/plistbuddy -c Print:CFBundleShortVersionString: build/Ferrite.xcarchive/Products/Applications/Ferrite.app/Info.plist)" >> $GITHUB_ENV + - name: Package ipa + run: | + mkdir Payload + cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload + zip -r Ferrite-iOS_v${{ env.app_version }}.ipa Payload + - name: Create ipa zip + run: | + zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa + - name: Upload release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index b47a44e..ec42081 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -8,35 +8,49 @@ /* 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 */; }; + 0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C2DD4CE29A6D47400293FC3 /* SwiftUIX */; }; 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 */; }; 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; }; - 0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */; }; + 0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridPickerView.swift */; }; 0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; }; 0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.swift */; }; + 0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 0C448BE829A135F100F4E266 /* Introspect-Static */; }; 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; }; 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; }; 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; }; 0C4CFC462897030D00AD9FAD /* Regex in Frameworks */ = {isa = PBXBuildFile; productRef = 0C4CFC452897030D00AD9FAD /* Regex */; }; 0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */; }; 0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */; }; + 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 */; }; + 0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C50B7CF299DF63C00A9FA3C /* UIDevice.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 /* ViewDidAppearHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */; }; + 0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4D299403B7003EEC05 /* ViewDidAppear.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 */; }; @@ -48,14 +62,12 @@ 0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; }; 0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; }; 0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; }; - 0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7376EF28A97D1400D60918 /* SwiftUIX */; }; 0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; }; 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 +80,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 */; }; + 0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; }; + 0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.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 */; }; @@ -91,7 +101,6 @@ 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CF288903F000DE2211 /* ToastViewModel.swift */; }; 0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */; }; 0CA148E9288903F000DE2211 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D1288903F000DE2211 /* MainView.swift */; }; - 0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D3288903F000DE2211 /* SearchResultsView.swift */; }; 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; }; 0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23328C2658700616D3A /* LibraryView.swift */; }; 0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23628C2660700616D3A /* HistoryView.swift */; }; @@ -104,41 +113,54 @@ 0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAF9318296399190050812A /* PremiumizeCloudView.swift */; }; 0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; }; 0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; }; - 0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 0CB6516728C5A5EC00DCA721 /* Introspect */; }; 0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */; }; 0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; }; 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; }; 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; }; 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; }; + 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 */; }; + 0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */; }; + 0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FC299C083B00476DDB /* PluginPickerView.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 */; }; + 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; }; + 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 0C0167DB29293FA900B65783 /* RealDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = ""; }; + 0C03EB6F296F619900162E9A /* PluginList+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataClass.swift"; sourceTree = ""; }; + 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = ""; }; 0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = ""; }; 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = ""; }; 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = ""; }; - 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = ""; }; + 0C0D50E6288DFF850035ECC8 /* PluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListView.swift; sourceTree = ""; }; 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = ""; }; 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryButtonView.swift; sourceTree = ""; }; 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = ""; }; 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = ""; }; + 0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = ""; }; 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = ""; }; 0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = ""; }; 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = ""; }; 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = ""; }; + 0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionModels.swift; sourceTree = ""; }; + 0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsView.swift; sourceTree = ""; }; + 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginModels.swift; sourceTree = ""; }; + 0C3E00D9296F5E4F00ECECB2 /* FerriteDB_v2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB_v2.xcdatamodel; sourceTree = ""; }; 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultButtonView.swift; sourceTree = ""; }; 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = ""; }; 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = ""; }; 0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = ""; }; - 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridChoiceView.swift; sourceTree = ""; }; + 0C42B5952932F2D5008057A0 /* DebridPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridPickerView.swift; sourceTree = ""; }; 0C42B5972932F6DD008057A0 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = ""; }; 0C445C61293F9A0B0060744D /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; @@ -146,8 +168,14 @@ 0C44E2AE28D52E8A007711AE /* BackupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsView.swift; sourceTree = ""; }; 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataClass.swift"; sourceTree = ""; }; 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = ""; }; + 0C5005512992B6750064606A /* PluginTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginTagsView.swift; sourceTree = ""; }; + 0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginTag+CoreDataClass.swift"; sourceTree = ""; }; + 0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginTag+CoreDataProperties.swift"; sourceTree = ""; }; + 0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = ""; }; 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = ""; }; + 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppearHandler.swift; sourceTree = ""; }; + 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppear.swift; sourceTree = ""; }; 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = ""; }; 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = ""; }; 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = ""; }; @@ -160,9 +188,8 @@ 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = ""; }; 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataProperties.swift"; sourceTree = ""; }; 0C78041C28BFB3EA001E8CA3 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; - 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceUpdateButtonView.swift; sourceTree = ""; }; - 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledSourceButtonView.swift; sourceTree = ""; }; - 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceCatalogButtonView.swift; sourceTree = ""; }; + 0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledPluginButtonView.swift; sourceTree = ""; }; + 0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginCatalogButtonView.swift; sourceTree = ""; }; 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataClass.swift"; sourceTree = ""; }; 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataProperties.swift"; sourceTree = ""; }; @@ -175,16 +202,14 @@ 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = ""; }; 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataClass.swift"; sourceTree = ""; }; 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataProperties.swift"; sourceTree = ""; }; - 0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceList+CoreDataClass.swift"; sourceTree = ""; }; - 0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceList+CoreDataProperties.swift"; sourceTree = ""; }; - 0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionsPickerViews.swift; sourceTree = ""; }; - 0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = ""; }; - 0CA05456288EE58200850554 /* SettingsSourceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSourceListView.swift; sourceTree = ""; }; - 0CA05458288EE9E600850554 /* SourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceManager.swift; sourceTree = ""; }; - 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceListEditorView.swift; sourceTree = ""; }; + 0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.swift; sourceTree = ""; }; + 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionPickerView.swift; sourceTree = ""; }; + 0CA05456288EE58200850554 /* SettingsPluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPluginListView.swift; sourceTree = ""; }; + 0CA05458288EE9E600850554 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; + 0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListEditorView.swift; sourceTree = ""; }; 0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = ""; }; - 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagnetChoiceView.swift; sourceTree = ""; }; + 0CA148BD288903F000DE2211 /* ActionChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionChoiceView.swift; sourceTree = ""; }; 0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = ""; }; 0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = ""; }; @@ -198,7 +223,6 @@ 0CA148CF288903F000DE2211 /* ToastViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = ""; }; 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealDebridWrapper.swift; sourceTree = ""; }; 0CA148D1288903F000DE2211 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; - 0CA148D3288903F000DE2211 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = ""; }; 0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 0CA3B23328C2658700616D3A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 0CA3B23628C2660700616D3A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; @@ -216,12 +240,19 @@ 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = ""; }; 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; }; 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = ""; }; + 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = ""; }; 0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = ""; }; 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = ""; }; + 0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScopeBar.swift; sourceTree = ""; }; + 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = ""; }; 0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = ""; }; + 0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = ""; }; 0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = ""; }; + 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -230,10 +261,10 @@ buildActionMask = 2147483647; files = ( 0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */, + 0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */, 0C64A4B4288903680079976D /* Base32 in Frameworks */, + 0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */, 0C4CFC462897030D00AD9FAD /* Regex in Frameworks */, - 0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */, - 0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */, 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */, 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */, 0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */, @@ -246,7 +277,7 @@ 0C0755C22934241F00ECA142 /* SheetViews */ = { isa = PBXGroup; children = ( - 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, + 0CA148BD288903F000DE2211 /* ActionChoiceView.swift */, 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, ); path = SheetViews; @@ -255,11 +286,11 @@ 0C0755C32934244500ECA142 /* ComponentViews */ = { isa = PBXGroup; children = ( + 0C3E00D4296F560800ECECB2 /* Plugin */, 0C0755C42934245800ECA142 /* Debrid */, 0CA3B23528C265FD00616D3A /* Library */, 0C44E2AB28D4E126007711AE /* SearchResult */, 0CA0545C288F7CB200850554 /* Settings */, - 0C794B65289DAC9F00DD1CC8 /* Source */, ); path = ComponentViews; sourceTree = ""; @@ -267,7 +298,7 @@ 0C0755C42934245800ECA142 /* Debrid */ = { isa = PBXGroup; children = ( - 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */, + 0C42B5952932F2D5008057A0 /* DebridPickerView.swift */, 0C0755C5293424A200ECA142 /* DebridLabelView.swift */, ); path = Debrid; @@ -276,6 +307,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 +329,6 @@ 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */, 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */, 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */, - 0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */, - 0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */, ); path = Classes; sourceTree = ""; @@ -301,6 +336,7 @@ 0C0D50E3288DFE6E0035ECC8 /* Models */ = { isa = PBXGroup; children = ( + 0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */, 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */, 0C7ED14028D61BBA009E29AD /* BackupModels.swift */, 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */, @@ -308,8 +344,8 @@ 0C422E7F293542F300486D65 /* PremiumizeModels.swift */, 0C0167DB29293FA900B65783 /* RealDebridModels.swift */, 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */, - 0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */, 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */, + 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */, ); path = Models; sourceTree = ""; @@ -324,6 +360,18 @@ path = Cloud; sourceTree = ""; }; + 0C3E00D4296F560800ECECB2 /* Plugin */ = { + isa = PBXGroup; + children = ( + 0C44E2AA28D4E09B007711AE /* Buttons */, + 0C794B65289DAC9F00DD1CC8 /* Source */, + 0C0D50E6288DFF850035ECC8 /* PluginListView.swift */, + 0C5005512992B6750064606A /* PluginTagsView.swift */, + 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */, + ); + path = Plugin; + sourceTree = ""; + }; 0C44E2A628D4DDC6007711AE /* Classes */ = { isa = PBXGroup; children = ( @@ -340,6 +388,8 @@ 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */, 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */, 0CB6516428C5A5D700DCA721 /* InlinedList.swift */, + 0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */, + 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */, ); path = Modifiers; sourceTree = ""; @@ -347,9 +397,8 @@ 0C44E2AA28D4E09B007711AE /* Buttons */ = { isa = PBXGroup; children = ( - 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */, - 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */, - 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */, + 0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */, + 0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.swift */, ); path = Buttons; sourceTree = ""; @@ -359,14 +408,22 @@ children = ( 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */, 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */, + 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */, ); path = SearchResult; sourceTree = ""; }; + 0C5005552992B9C20064606A /* Protocols */ = { + isa = PBXGroup; + children = ( + 0CE1C4172981E8D700418F20 /* Plugin.swift */, + ); + path = Protocols; + sourceTree = ""; + }; 0C794B65289DAC9F00DD1CC8 /* Source */ = { isa = PBXGroup; children = ( - 0C44E2AA28D4E09B007711AE /* Buttons */, 0C733286289C4C820058D1FE /* SourceSettingsView.swift */, ); path = Source; @@ -376,9 +433,9 @@ isa = PBXGroup; children = ( 0C44E2AE28D52E8A007711AE /* BackupsView.swift */, - 0CA05456288EE58200850554 /* SettingsSourceListView.swift */, - 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */, - 0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */, + 0CA05456288EE58200850554 /* SettingsPluginListView.swift */, + 0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */, + 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */, 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */, ); path = Settings; @@ -394,6 +451,7 @@ 0CA148EF2889061600DE2211 /* ViewModels */, 0CA148EE2889061200DE2211 /* Views */, 0C44E2A628D4DDC6007711AE /* Classes */, + 0C5005552992B9C20064606A /* Protocols */, 0CA148C8288903F000DE2211 /* Extensions */, 0CA148C5288903F000DE2211 /* Preview Content */, 0CA148C7288903F000DE2211 /* FerriteApp.swift */, @@ -411,10 +469,12 @@ 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */, 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */, 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */, + 0C871BDE29994D9D005279AC /* FilterLabelView.swift */, 0CA148C1288903F000DE2211 /* NavView.swift */, 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */, 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */, 0C32FB562890D1F2002BD219 /* ListRowViews.swift */, + 0C2D9652299316CC00A504B6 /* Tag.swift */, ); path = CommonViews; sourceTree = ""; @@ -442,6 +502,7 @@ 0C7ED14228D65518009E29AD /* FileManager.swift */, 0C42B5972932F6DD008057A0 /* Set.swift */, 0C7C128528DAA3CD00381CD1 /* URL.swift */, + 0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */, ); path = Extensions; sourceTree = ""; @@ -455,9 +516,8 @@ 0C0755C22934241F00ECA142 /* SheetViews */, 0CA148D1288903F000DE2211 /* MainView.swift */, 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 +532,7 @@ 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */, 0CA148CF288903F000DE2211 /* ToastViewModel.swift */, 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */, - 0CA05458288EE9E600850554 /* SourceManager.swift */, + 0CA05458288EE9E600850554 /* PluginManager.swift */, 0C44E2AC28D51C63007711AE /* BackupManager.swift */, ); path = ViewModels; @@ -482,6 +542,7 @@ isa = PBXGroup; children = ( 0CA148CE288903F000DE2211 /* WebView.swift */, + 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */, ); path = RepresentableViews; sourceTree = ""; @@ -506,6 +567,7 @@ 0CA3B23628C2660700616D3A /* HistoryView.swift */, 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */, 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */, + 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */, ); path = Library; sourceTree = ""; @@ -558,10 +620,10 @@ 0C64A4B3288903680079976D /* Base32 */, 0C64A4B6288903880079976D /* KeychainSwift */, 0C4CFC452897030D00AD9FAD /* Regex */, - 0C7376EF28A97D1400D60918 /* SwiftUIX */, 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */, - 0CB6516728C5A5EC00DCA721 /* Introspect */, 0CDDDE042935235E006810B1 /* BetterSafariView */, + 0C448BE829A135F100F4E266 /* Introspect-Static */, + 0C2DD4CE29A6D47400293FC3 /* SwiftUIX */, ); productName = Torrenter; productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */; @@ -596,10 +658,10 @@ 0C64A4B2288903680079976D /* XCRemoteSwiftPackageReference "Base32" */, 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */, 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */, - 0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */, 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, - 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */, + 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + 0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */, ); productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */; projectDirPath = ""; @@ -652,46 +714,57 @@ 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 */, + 0C42B5962932F2D5008057A0 /* DebridPickerView.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 */, + 0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */, + 0C0D50E7288DFF850035ECC8 /* PluginListView.swift in Sources */, 0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */, 0CD72E17293D9928001A7EA4 /* Array.swift in Sources */, + 0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.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 */, - 0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */, + 0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */, + 0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */, + 0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */, + 0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.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,11 +772,9 @@ 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 */, 0CA148E3288903F000DE2211 /* Task.swift in Sources */, 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */, 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */, @@ -712,7 +783,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,31 +792,38 @@ 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 */, + 0C871BDF29994D9D005279AC /* FilterLabelView.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 /* ViewDidAppear.swift in Sources */, 0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */, 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */, 0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */, + 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */, + 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */, 0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */, 0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */, + 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */, 0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */, 0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */, 0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */, 0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */, + 0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */, 0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */, - 0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */, 0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */, 0CA148E0288903F000DE2211 /* FerriteApp.swift in Sources */, ); @@ -894,7 +971,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.6.0; + MARKETING_VERSION = 0.6.1; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -929,7 +1006,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.6.0; + MARKETING_VERSION = 0.6.1; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -963,6 +1040,22 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SwiftUIX/SwiftUIX"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.3; + }; + }; + 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect/"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.2.2; + }; + }; 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/Regex"; @@ -987,14 +1080,6 @@ kind = branch; }; }; - 0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SwiftUIX/SwiftUIX"; - requirement = { - branch = master; - kind = branch; - }; - }; 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON"; @@ -1011,14 +1096,6 @@ minimumVersion = 2.0.0; }; }; - 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; - requirement = { - branch = master; - kind = branch; - }; - }; 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/stleamist/BetterSafariView"; @@ -1030,6 +1107,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0C2DD4CE29A6D47400293FC3 /* SwiftUIX */ = { + isa = XCSwiftPackageProductDependency; + package = 0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */; + productName = SwiftUIX; + }; + 0C448BE829A135F100F4E266 /* Introspect-Static */ = { + isa = XCSwiftPackageProductDependency; + package = 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = "Introspect-Static"; + }; 0C4CFC452897030D00AD9FAD /* Regex */ = { isa = XCSwiftPackageProductDependency; package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */; @@ -1045,11 +1132,6 @@ package = 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */; productName = KeychainSwift; }; - 0C7376EF28A97D1400D60918 /* SwiftUIX */ = { - isa = XCSwiftPackageProductDependency; - package = 0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */; - productName = SwiftUIX; - }; 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */ = { isa = XCSwiftPackageProductDependency; package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; @@ -1060,11 +1142,6 @@ package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; - 0CB6516728C5A5EC00DCA721 /* Introspect */ = { - isa = XCSwiftPackageProductDependency; - package = 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; - productName = Introspect; - }; 0CDDDE042935235E006810B1 /* BetterSafariView */ = { isa = XCSwiftPackageProductDependency; package = 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */; @@ -1076,9 +1153,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 = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index 9b92a58..a81f69c 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -18,6 +18,16 @@ public class RealDebrid { var authTask: Task? + @MainActor + func setUserDefaultsValue(_ value: Any, forKey: String) { + UserDefaults.standard.set(value, forKey: forKey) + } + + @MainActor + func removeUserDefaultsValue(forKey: String) { + UserDefaults.standard.removeObject(forKey: forKey) + } + // Fetches the device code from RD public func getVerificationInfo() async throws -> DeviceCodeResponse { var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")! @@ -72,7 +82,7 @@ public class RealDebrid { // If there's a client ID from the response, end the task successfully if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret { - UserDefaults.standard.set(clientId, forKey: "RealDebrid.ClientId") + await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId") keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret") try await getTokens(deviceCode: deviceCode) @@ -124,7 +134,7 @@ public class RealDebrid { keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken") let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn) - UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") + await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") } public func fetchToken() async -> String? { @@ -147,8 +157,8 @@ public class RealDebrid { public func deleteTokens() async throws { keychain.delete("RealDebrid.RefreshToken") keychain.delete("RealDebrid.ClientSecret") - UserDefaults.standard.removeObject(forKey: "RealDebrid.ClientId") - UserDefaults.standard.removeObject(forKey: "RealDebrid.AccessTokenStamp") + await removeUserDefaultsValue(forKey: "RealDebrid.ClientId") + await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp") // Run the request, doesn't matter if it fails if let token = keychain.get("RealDebrid.AccessToken") { diff --git a/Ferrite/DataManagement/Classes/Action+CoreDataClass.swift b/Ferrite/DataManagement/Classes/Action+CoreDataClass.swift new file mode 100644 index 0000000..c060b75 --- /dev/null +++ b/Ferrite/DataManagement/Classes/Action+CoreDataClass.swift @@ -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 {} diff --git a/Ferrite/DataManagement/Classes/Action+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/Action+CoreDataProperties.swift new file mode 100644 index 0000000..77a867b --- /dev/null +++ b/Ferrite/DataManagement/Classes/Action+CoreDataProperties.swift @@ -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 { + return NSFetchRequest(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 { + +} diff --git a/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift index c12c191..730fbdb 100644 --- a/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift +++ b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift @@ -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 {} diff --git a/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift index 36f2e9e..39a6268 100644 --- a/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift @@ -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 {} diff --git a/Ferrite/DataManagement/Classes/PluginList+CoreDataClass.swift b/Ferrite/DataManagement/Classes/PluginList+CoreDataClass.swift new file mode 100644 index 0000000..3788461 --- /dev/null +++ b/Ferrite/DataManagement/Classes/PluginList+CoreDataClass.swift @@ -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 { + +} diff --git a/Ferrite/DataManagement/Classes/PluginList+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/PluginList+CoreDataProperties.swift new file mode 100644 index 0000000..1acc669 --- /dev/null +++ b/Ferrite/DataManagement/Classes/PluginList+CoreDataProperties.swift @@ -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 { + return NSFetchRequest(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 { + +} diff --git a/Ferrite/DataManagement/Classes/PluginTag+CoreDataClass.swift b/Ferrite/DataManagement/Classes/PluginTag+CoreDataClass.swift new file mode 100644 index 0000000..cb9a095 --- /dev/null +++ b/Ferrite/DataManagement/Classes/PluginTag+CoreDataClass.swift @@ -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 { +} diff --git a/Ferrite/DataManagement/Classes/PluginTag+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/PluginTag+CoreDataProperties.swift new file mode 100644 index 0000000..b10b144 --- /dev/null +++ b/Ferrite/DataManagement/Classes/PluginTag+CoreDataProperties.swift @@ -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 { + return NSFetchRequest(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 { + +} diff --git a/Ferrite/DataManagement/Classes/Source+CoreDataClass.swift b/Ferrite/DataManagement/Classes/Source+CoreDataClass.swift index 1196354..1158192 100644 --- a/Ferrite/DataManagement/Classes/Source+CoreDataClass.swift +++ b/Ferrite/DataManagement/Classes/Source+CoreDataClass.swift @@ -10,4 +10,4 @@ import CoreData import Foundation @objc(Source) -public class Source: NSManagedObject {} +public class Source: NSManagedObject, Plugin {} diff --git a/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift index 97b85ee..daeffc8 100644 --- a/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift @@ -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 { - NSFetchRequest(entityName: "Source") + +extension Source { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(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 { + +} diff --git a/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift index 1cc03aa..e5f19bd 100644 --- a/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift @@ -22,6 +22,7 @@ public extension SourceHtmlParser { @NSManaged var seedLeech: SourceSeedLeech? @NSManaged var size: SourceSize? @NSManaged var title: SourceTitle? + @NSManaged var subName: SourceSubName? } extension SourceHtmlParser: Identifiable {} diff --git a/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift index 84de4d3..81d7287 100644 --- a/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift @@ -23,6 +23,7 @@ public extension SourceJsonParser { @NSManaged var seedLeech: SourceSeedLeech? @NSManaged var size: SourceSize? @NSManaged var title: SourceTitle? + @NSManaged var subName: SourceSubName? } extension SourceJsonParser: Identifiable {} diff --git a/Ferrite/DataManagement/Classes/SourceList+CoreDataClass.swift b/Ferrite/DataManagement/Classes/SourceList+CoreDataClass.swift deleted file mode 100644 index c6f26e5..0000000 --- a/Ferrite/DataManagement/Classes/SourceList+CoreDataClass.swift +++ /dev/null @@ -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 {} diff --git a/Ferrite/DataManagement/Classes/SourceList+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceList+CoreDataProperties.swift deleted file mode 100644 index db7bb72..0000000 --- a/Ferrite/DataManagement/Classes/SourceList+CoreDataProperties.swift +++ /dev/null @@ -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 { - NSFetchRequest(entityName: "SourceList") - } - - @NSManaged var id: UUID - @NSManaged var author: String - @NSManaged var name: String - @NSManaged var urlString: String -} - -extension SourceList: Identifiable {} diff --git a/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift index eef0e62..6e8cc8e 100644 --- a/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift @@ -23,6 +23,7 @@ public extension SourceRssParser { @NSManaged var seedLeech: SourceSeedLeech? @NSManaged var size: SourceSize? @NSManaged var title: SourceTitle? + @NSManaged var subName: SourceSubName? } extension SourceRssParser: Identifiable {} diff --git a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/.xccurrentversion b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/.xccurrentversion index c089bb1..f0842c7 100644 --- a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/.xccurrentversion +++ b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - FerriteDB.xcdatamodel + FerriteDB_v2.xcdatamodel diff --git a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB_v2.xcdatamodel/contents b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB_v2.xcdatamodel/contents new file mode 100644 index 0000000..305d94c --- /dev/null +++ b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB_v2.xcdatamodel/contents @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Ferrite/DataManagement/PersistenceController.swift b/Ferrite/DataManagement/PersistenceController.swift index fc389a2..dd0895d 100644 --- a/Ferrite/DataManagement/PersistenceController.swift +++ b/Ferrite/DataManagement/PersistenceController.swift @@ -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(entityName: entity) let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) batchDeleteRequest.resultType = .resultTypeObjectIDs diff --git a/Ferrite/Extensions/UIDevice.swift b/Ferrite/Extensions/UIDevice.swift new file mode 100644 index 0000000..f2ae7e6 --- /dev/null +++ b/Ferrite/Extensions/UIDevice.swift @@ -0,0 +1,18 @@ +// +// UIDevice.swift +// Ferrite +// +// Created by Brian Dashore on 2/16/23. +// + +import SwiftUI + +extension UIDevice { + var hasNotch: Bool { + if #available(iOS 11.0, *) { + let keyWindow = UIApplication.shared.windows.filter(\.isKeyWindow).first + return keyWindow?.safeAreaInsets.bottom ?? 0 > 0 + } + return false + } +} diff --git a/Ferrite/Extensions/View.swift b/Ferrite/Extensions/View.swift index 032567f..1a7f245 100644 --- a/Ferrite/Extensions/View.swift +++ b/Ferrite/Extensions/View.swift @@ -9,51 +9,39 @@ import Introspect import SwiftUI extension View { - // MARK: Custom introspect functions - - func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View { - inject(UIKitIntrospectionView( - selector: { introspectionView in - guard let viewHost = Introspect.findViewHost(from: introspectionView) else { - return nil - } - return Introspect.previousSibling(containing: UICollectionView.self, from: viewHost) - }, - customize: customize - )) - } - - // From https://github.com/siteline/SwiftUI-Introspect/pull/129 - public func introspectSearchController(customize: @escaping (UISearchController) -> Void) -> some View { - introspectNavigationController { navigationController in - let navigationBar = navigationController.navigationBar - if let searchController = navigationBar.topItem?.searchController { - customize(searchController) - } - } - } - // MARK: Modifiers func conditionalContextMenu(id: some Hashable, @ViewBuilder _ internalContent: @escaping () -> some View) -> some View { - modifier(ConditionalContextMenu(internalContent, id: id)) + modifier(ConditionalContextMenuModifier(internalContent, id: id)) } func conditionalId(_ id: some Hashable) -> some View { - modifier(ConditionalId(id: id)) + modifier(ConditionalIdModifier(id: id)) } func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View { - modifier(DisabledAppearance(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation)) + modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation)) } func disableInteraction(_ disabled: Bool) -> some View { - modifier(DisableInteraction(disabled: disabled)) + modifier(DisableInteractionModifier(disabled: disabled)) } - func inlinedList() -> some View { - modifier(InlinedList()) + func inlinedList(inset: CGFloat) -> some View { + modifier(InlinedListModifier(inset: inset)) + } + + func viewDidAppear(_ callback: @escaping () -> Void) -> some View { + modifier(ViewDidAppearModifier(callback: callback)) + } + + func customScopeBar(_ content: Content) -> some View { + modifier(CustomScopeBarModifier(hostingContent: content)) + } + + func customScopeBar(_ content: @escaping () -> Content) -> some View { + modifier(CustomScopeBarModifier(hostingContent: content())) } } diff --git a/Ferrite/FerriteApp.swift b/Ferrite/FerriteApp.swift index 3f26413..7e5fcc8 100644 --- a/Ferrite/FerriteApp.swift +++ b/Ferrite/FerriteApp.swift @@ -15,16 +15,16 @@ 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 { WindowGroup { MainView() - .onAppear { + .backport.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) } diff --git a/Ferrite/Models/ActionModels.swift b/Ferrite/Models/ActionModels.swift new file mode 100644 index 0000000..410605b --- /dev/null +++ b/Ferrite/Models/ActionModels.swift @@ -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 +} diff --git a/Ferrite/Models/BackupModels.swift b/Ferrite/Models/BackupModels.swift index 210eea8..4a5e2ce 100644 --- a/Ferrite/Models/BackupModels.swift +++ b/Ferrite/Models/BackupModels.swift @@ -7,12 +7,17 @@ import Foundation +// Version is optional until v1 is phased out public struct Backup: Codable { - let version: Int + let version: Int? 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 +48,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 diff --git a/Ferrite/Models/PluginModels.swift b/Ferrite/Models/PluginModels.swift new file mode 100644 index 0000000..ee97b3f --- /dev/null +++ b/Ferrite/Models/PluginModels.swift @@ -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) + } +} diff --git a/Ferrite/Models/SettingsModels.swift b/Ferrite/Models/SettingsModels.swift deleted file mode 100644 index 3f20e57..0000000 --- a/Ferrite/Models/SettingsModels.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// SettingsModels.swift -// Ferrite -// -// Created by Brian Dashore on 8/11/22. -// - -import Foundation - -public enum DefaultMagnetActionType: Int, CaseIterable { - // Let the user choose - case none = 0 - - // Open in actions come first - case webtor = 1 - - // Sharing actions come last - case shareMagnet = 2 -} - -public enum DefaultDebridActionType: Int, CaseIterable { - // Let the user choose - case none = 0 - - // Open in actions come first - case outplayer = 1 - case vlc = 2 - case infuse = 3 - - // Sharing actions come last - case shareDownload = 4 -} diff --git a/Ferrite/Models/SourceModels.swift b/Ferrite/Models/SourceModels.swift index 8cf3221..3bb45b8 100644 --- a/Ferrite/Models/SourceModels.swift +++ b/Ferrite/Models/SourceModels.swift @@ -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 dynamicBaseUrl: Bool? 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 { @@ -60,10 +62,11 @@ public struct SourceJsonParserJson: Codable, Hashable, Sendable { let searchUrl: String let results: String? let subResults: String? - let magnetHash: SouceComplexQueryJson? - let magnetLink: SouceComplexQueryJson? - let title: SouceComplexQueryJson? - let size: SouceComplexQueryJson? + let magnetHash: SourceComplexQueryJson? + let magnetLink: SourceComplexQueryJson? + let subName: SourceComplexQueryJson? + let title: SourceComplexQueryJson? + let size: SourceComplexQueryJson? let sl: SourceSLJson? } @@ -71,10 +74,11 @@ public struct SourceRssParserJson: Codable, Hashable, Sendable { let rssUrl: String? let searchUrl: String let items: String - let magnetHash: SouceComplexQueryJson? - let magnetLink: SouceComplexQueryJson? - let title: SouceComplexQueryJson? - let size: SouceComplexQueryJson? + let magnetHash: SourceComplexQueryJson? + let magnetLink: SourceComplexQueryJson? + let subName: SourceComplexQueryJson? + let title: SourceComplexQueryJson? + let size: SourceComplexQueryJson? let sl: SourceSLJson? } @@ -82,12 +86,13 @@ public struct SourceHtmlParserJson: Codable, Hashable, Sendable { let searchUrl: String let rows: String let magnet: SourceMagnetJson - let title: SouceComplexQueryJson? - let size: SouceComplexQueryJson? + let subName: SourceComplexQueryJson? + let title: SourceComplexQueryJson? + let size: SourceComplexQueryJson? let sl: SourceSLJson? } -public struct SouceComplexQueryJson: Codable, Hashable, Sendable { +public struct SourceComplexQueryJson: Codable, Hashable, Sendable { let query: String let discriminator: String? let attribute: String? diff --git a/Ferrite/Protocols/Plugin.swift b/Ferrite/Protocols/Plugin.swift new file mode 100644 index 0000000..5e53d27 --- /dev/null +++ b/Ferrite/Protocols/Plugin.swift @@ -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] +} diff --git a/Ferrite/ViewModels/BackupManager.swift b/Ferrite/ViewModels/BackupManager.swift index bf7e534..70fd27a 100644 --- a/Ferrite/ViewModels/BackupManager.swift +++ b/Ferrite/ViewModels/BackupManager.swift @@ -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,74 @@ 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 { + let version = backup.version ?? -1 + + if let storedLists = backup.sourceLists, version < 2 { + // Only present in v1 or no version 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 +212,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)") } } } diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 8498868..b91e4a5 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -19,7 +19,6 @@ public class DebridManager: ObservableObject { // UI Variables @Published var showWebView: Bool = false @Published var showAuthSession: Bool = false - @Published var showLoadingProgress: Bool = false // Service agnostic variables @Published var enabledDebrids: Set = [] { @@ -50,6 +49,7 @@ public class DebridManager: ObservableObject { var selectedRealDebridFile: RealDebrid.IAFile? var selectedRealDebridID: String? + // TODO: Maybe make these generic? // RealDebrid cloud variables @Published var realDebridCloudTorrents: [RealDebrid.UserTorrentsResponse] = [] @Published var realDebridCloudDownloads: [RealDebrid.UserDownloadsResponse] = [] @@ -479,10 +479,13 @@ public class DebridManager: ObservableObject { public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async { defer { currentDebridTask = nil - showLoadingProgress = false + toastModel?.hideIndeterminateToast() } - showLoadingProgress = true + toastModel?.updateIndeterminateToast("Loading content", cancelAction: { + self.currentDebridTask?.cancel() + self.currentDebridTask = nil + }) switch selectedDebridType { case .realDebrid: @@ -557,7 +560,7 @@ public class DebridManager: ObservableObject { await deleteRdTorrent(torrentID: selectedRealDebridID, presentError: false) } - showLoadingProgress = false + toastModel?.hideIndeterminateToast() } } diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index 66aae92..45e4c57 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -7,30 +7,38 @@ import SwiftUI -enum ViewTab { - case search - case sources - case settings - case library -} - @MainActor -class NavigationViewModel: ObservableObject { +public class NavigationViewModel: ObservableObject { var toastModel: ToastViewModel? // Used between SearchResultsView and MagnetChoiceView - enum ChoiceSheetType: Identifiable { - var id: Int { + public enum ChoiceSheetType: Identifiable { + public var id: Int { hashValue } - case magnet + case action case batch case activity } - @Published var isEditingSearch: Bool = false - @Published var isSearching: Bool = false + enum ViewTab { + case search + case plugins + case settings + case library + } + + enum LibraryPickerSegment { + case bookmarks + case history + case debridCloud + } + + enum PluginPickerSegment { + case sources + case actions + } @Published var selectedMagnet: Magnet? @Published var selectedHistoryInfo: HistoryEntryJson? @@ -49,82 +57,13 @@ class NavigationViewModel: ObservableObject { @Published var showLocalActivitySheet = false @Published var selectedTab: ViewTab = .search - @Published var showSearchProgress: Bool = false // Used between SourceListView and SourceSettingsView @Published var showSourceSettings: Bool = false 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 - - public func runDebridAction(urlString: String, _ action: DefaultDebridActionType? = nil) { - let selectedAction = action ?? defaultDebridAction - - switch selectedAction { - case .none: - currentChoiceSheet = .magnet - case .outplayer: - if let downloadUrl = URL(string: "outplayer://\(urlString)") { - UIApplication.shared.open(downloadUrl) - } else { - toastModel?.updateToastDescription("Could not create an Outplayer URL") - } - case .vlc: - if let downloadUrl = URL(string: "vlc://\(urlString)") { - UIApplication.shared.open(downloadUrl) - } else { - toastModel?.updateToastDescription("Could not create a VLC URL") - } - case .infuse: - if let downloadUrl = URL(string: "infuse://x-callback-url/play?url=\(urlString)") { - UIApplication.shared.open(downloadUrl) - } else { - toastModel?.updateToastDescription("Could not create a Infuse URL") - } - case .shareDownload: - if let downloadUrl = URL(string: urlString), currentChoiceSheet == nil { - activityItems = [downloadUrl] - currentChoiceSheet = .activity - } else { - toastModel?.updateToastDescription("Could not create object for sharing") - } - } - } - - public func runMagnetAction(magnet: Magnet?, _ action: DefaultMagnetActionType? = nil) { - // Fall back to selected magnet if the provided magnet is nil - let magnet = magnet ?? selectedMagnet - guard let magnetLink = magnet?.link else { - toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") - print("Magnet action error: The magnet link is invalid.") - - return - } - - let selectedAction = action ?? defaultMagnetAction - - switch selectedAction { - case .none: - currentChoiceSheet = .magnet - case .webtor: - if let url = URL(string: "https://webtor.io/#/show?magnet=\(magnetLink)") { - UIApplication.shared.open(url) - } else { - toastModel?.updateToastDescription("Could not create a WebTor URL") - } - case .shareMagnet: - if let magnetUrl = URL(string: magnetLink), - currentChoiceSheet == nil - { - activityItems = [magnetUrl] - currentChoiceSheet = .activity - } else { - toastModel?.updateToastDescription("Could not create object for sharing") - } - } - } + @Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks + @Published var pluginPickerSelection: PluginPickerSegment = .sources } diff --git a/Ferrite/ViewModels/PluginManager.swift b/Ferrite/ViewModels/PluginManager.swift new file mode 100644 index 0000000..b370b64 --- /dev/null +++ b/Ferrite/ViewModels/PluginManager.swift @@ -0,0 +1,685 @@ +// +// SourceManager.swift +// Ferrite +// +// Created by Brian Dashore on 7/25/22. +// + +import Foundation +import SwiftUI + +public class PluginManager: ObservableObject { + var toastModel: ToastViewModel? + + @Published var availableSources: [SourceJson] = [] + @Published var availableActions: [ActionJson] = [] + + @Published var showBrokenDefaultActionAlert = false + + @MainActor + public func fetchPluginsFromUrl() async { + let pluginListRequest = PluginList.fetchRequest() + do { + let pluginLists = try PersistenceController.shared.backgroundContext.fetch(pluginListRequest) + + // Clean availablePlugin arrays for repopulation + availableSources = [] + availableActions = [] + + for pluginList in pluginLists { + guard let url = URL(string: pluginList.urlString) else { + return + } + + // Always get the up-to-date source list + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + + let (data, _) = try await URLSession.shared.data(for: request) + let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data) + + 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, + dynamicBaseUrl: inputJson.dynamicBaseUrl, + 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 + } + } + } + + 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 + } + } + } + } + } catch { + let error = error as NSError + if error.code != -999 { + toastModel?.updateToastDescription("Plugin fetch error: \(error)") + } + + print("Plugin fetch error: \(error)") + } + } + + // forType required to guide generic inferences + func fetchFilteredPlugins( + forType: PJ.Type, + installedPlugins: FetchedResults

, + searchText: String + ) -> [PJ] { + let availablePlugins: [PJ] = fetchCastedPlugins(forType) + + 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( + forType: PJ.Type, + installedPlugins: FetchedResults

, + searchText: String + ) -> [PJ] { + var updatedPlugins: [PJ] = [] + let availablePlugins: [PJ] = fetchCastedPlugins(forType) + + for plugin in installedPlugins { + if let availablePlugin = availablePlugins.first(where: { + plugin.listId == $0.listId && + plugin.name == $0.name && + plugin.author == $0.author + }), + availablePlugin.version > plugin.version + { + updatedPlugins.append(availablePlugin) + } + } + + return updatedPlugins + .filter { + searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased()) + } + } + + func fetchCastedPlugins(_ 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 + func checkAppVersion(minVersion: String?) -> Bool { + // If there's no min version, assume that every version is supported + guard let minVersion else { + return true + } + + return Application.shared.appVersion >= minVersion + } + + // Fetches sources using the background context + public func fetchInstalledSources() -> [Source] { + let backgroundContext = PersistenceController.shared.backgroundContext + + if let sources = try? backgroundContext.fetch(Source.fetchRequest()) { + return sources.compactMap { $0 } + } else { + return [] + } + } + + @MainActor + public func runDebridAction(urlString: String?, currentChoiceSheet: inout NavigationViewModel.ChoiceSheetType?) { + let context = PersistenceController.shared.backgroundContext + + if + let defaultDebridActionName = UserDefaults.standard.string(forKey: "Actions.DefaultDebridName"), + let defaultDebridActionList = UserDefaults.standard.string(forKey: "Actions.DefaultDebridList") + { + let actionFetchRequest = Action.fetchRequest() + actionFetchRequest.fetchLimit = 1 + actionFetchRequest.predicate = NSPredicate(format: "name == %@ AND listId == %@", defaultDebridActionName, defaultDebridActionList) + + if let fetchedAction = try? context.fetch(actionFetchRequest).first { + runDeeplinkAction(fetchedAction, urlString: urlString) + } else { + currentChoiceSheet = .action + UserDefaults.standard.set(nil, forKey: "Actions.DefaultDebridName") + UserDefaults.standard.set(nil, forKey: "Action.DefaultDebridList") + + showBrokenDefaultActionAlert.toggle() + } + } else { + currentChoiceSheet = .action + } + } + + @MainActor + public func runMagnetAction(urlString: String?, currentChoiceSheet: inout NavigationViewModel.ChoiceSheetType?) { + let context = PersistenceController.shared.backgroundContext + + if + let defaultMagnetActionName = UserDefaults.standard.string(forKey: "Actions.DefaultMagnetName"), + let defaultMagnetActionList = UserDefaults.standard.string(forKey: "Actions.DefaultMagnetList") + { + let actionFetchRequest = Action.fetchRequest() + actionFetchRequest.fetchLimit = 1 + actionFetchRequest.predicate = NSPredicate(format: "name == %@ AND listId == %@", defaultMagnetActionName, defaultMagnetActionList) + + if let fetchedAction = try? context.fetch(actionFetchRequest).first { + runDeeplinkAction(fetchedAction, urlString: urlString) + } else { + currentChoiceSheet = .action + UserDefaults.standard.set(nil, forKey: "Actions.DefaultMagnetName") + UserDefaults.standard.set(nil, forKey: "Actions.DefaultMagnetList") + + showBrokenDefaultActionAlert.toggle() + } + } else { + currentChoiceSheet = .action + } + } + + // 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 + let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false + 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 source \(sourceJson.name) because base URL parameters are malformed") + + return + } + + // If a source exists, don't add the new one unless upserting + let existingSourceRequest = Source.fetchRequest() + existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name) + existingSourceRequest.fetchLimit = 1 + + if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first { + if doUpsert { + 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 + } + } + + let newSource = Source(context: backgroundContext) + newSource.id = UUID() + newSource.name = sourceJson.name + newSource.version = sourceJson.version + newSource.dynamicBaseUrl = dynamicBaseUrl + newSource.baseUrl = sourceJson.baseUrl + newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls + newSource.author = sourceJson.author ?? "Unknown" + 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) + } + + if let jsonParserJson = sourceJson.jsonParser { + addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson) + } + + // Adds an RSS parser if present + if let rssParserJson = sourceJson.rssParser { + addRssParser(newSource: newSource, rssParserJson: rssParserJson) + } + + // Adds an HTML parser if present + if let htmlParserJson = sourceJson.htmlParser { + addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson) + } + + // Add an API condition as well + if newSource.jsonParser != nil { + newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue) + } else if newSource.rssParser != nil { + newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue) + } else { + newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue) + } + + newSource.enabled = true + + do { + try backgroundContext.save() + } catch { + await toastModel?.updateToastDescription("Source addition error: \(error)") + print("Source addition error: \(error)") + } + } + + func addSourceApi(newSource: Source, apiJson: SourceApiJson) { + let backgroundContext = PersistenceController.shared.backgroundContext + + let newSourceApi = SourceApi(context: backgroundContext) + newSourceApi.apiUrl = apiJson.apiUrl + + if let clientIdJson = apiJson.clientId { + let newClientId = SourceApiClientId(context: backgroundContext) + newClientId.query = clientIdJson.query + newClientId.urlString = clientIdJson.url + newClientId.dynamic = clientIdJson.dynamic ?? false + newClientId.value = clientIdJson.value + newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue + newClientId.expiryLength = clientIdJson.expiryLength ?? 0 + newClientId.timeStamp = Date() + + newSourceApi.clientId = newClientId + } + + if let clientSecretJson = apiJson.clientSecret { + let newClientSecret = SourceApiClientSecret(context: backgroundContext) + newClientSecret.query = clientSecretJson.query + newClientSecret.urlString = clientSecretJson.url + newClientSecret.dynamic = clientSecretJson.dynamic ?? false + newClientSecret.value = clientSecretJson.value + newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue + newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0 + newClientSecret.timeStamp = Date() + + newSourceApi.clientSecret = newClientSecret + } + + newSource.api = newSourceApi + } + + func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) { + let backgroundContext = PersistenceController.shared.backgroundContext + + let newSourceJsonParser = SourceJsonParser(context: backgroundContext) + newSourceJsonParser.searchUrl = jsonParserJson.searchUrl + newSourceJsonParser.results = jsonParserJson.results + newSourceJsonParser.subResults = jsonParserJson.subResults + + // Tune these complex queries to the final JSON parser format + if let magnetLinkJson = jsonParserJson.magnetLink { + let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) + newSourceMagnetLink.query = magnetLinkJson.query + newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text" + newSourceMagnetLink.discriminator = magnetLinkJson.discriminator + + newSourceJsonParser.magnetLink = newSourceMagnetLink + } + + if let magnetHashJson = jsonParserJson.magnetHash { + let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext) + newSourceMagnetHash.query = magnetHashJson.query + newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text" + newSourceMagnetHash.discriminator = magnetHashJson.discriminator + + newSourceJsonParser.magnetHash = newSourceMagnetHash + } + + if let subNameJson = jsonParserJson.subName { + let newSourceSubName = SourceSubName(context: backgroundContext) + newSourceSubName.query = subNameJson.query + newSourceSubName.attribute = subNameJson.query + newSourceSubName.discriminator = subNameJson.discriminator + + newSourceJsonParser.subName = newSourceSubName + } + + if let titleJson = jsonParserJson.title { + let newSourceTitle = SourceTitle(context: backgroundContext) + newSourceTitle.query = titleJson.query + newSourceTitle.attribute = titleJson.attribute ?? "text" + newSourceTitle.discriminator = titleJson.discriminator + + newSourceJsonParser.title = newSourceTitle + } + + if let sizeJson = jsonParserJson.size { + let newSourceSize = SourceSize(context: backgroundContext) + newSourceSize.query = sizeJson.query + newSourceSize.attribute = sizeJson.attribute ?? "text" + newSourceSize.discriminator = sizeJson.discriminator + + newSourceJsonParser.size = newSourceSize + } + + if let seedLeechJson = jsonParserJson.sl { + let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext) + newSourceSeedLeech.seeders = seedLeechJson.seeders + newSourceSeedLeech.leechers = seedLeechJson.leechers + newSourceSeedLeech.combined = seedLeechJson.combined + newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text" + newSourceSeedLeech.discriminator = seedLeechJson.discriminator + newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex + newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex + + newSourceJsonParser.seedLeech = newSourceSeedLeech + } + + newSource.jsonParser = newSourceJsonParser + } + + func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) { + let backgroundContext = PersistenceController.shared.backgroundContext + + let newSourceRssParser = SourceRssParser(context: backgroundContext) + newSourceRssParser.rssUrl = rssParserJson.rssUrl + newSourceRssParser.searchUrl = rssParserJson.searchUrl + newSourceRssParser.items = rssParserJson.items + + if let magnetLinkJson = rssParserJson.magnetLink { + let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) + newSourceMagnetLink.query = magnetLinkJson.query + newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text" + newSourceMagnetLink.discriminator = magnetLinkJson.discriminator + newSourceMagnetLink.regex = magnetLinkJson.regex + + newSourceRssParser.magnetLink = newSourceMagnetLink + } + + if let magnetHashJson = rssParserJson.magnetHash { + let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext) + newSourceMagnetHash.query = magnetHashJson.query + newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text" + newSourceMagnetHash.discriminator = magnetHashJson.discriminator + newSourceMagnetHash.regex = magnetHashJson.regex + + newSourceRssParser.magnetHash = newSourceMagnetHash + } + + if let subNameJson = rssParserJson.subName { + let newSourceSubName = SourceSubName(context: backgroundContext) + newSourceSubName.query = subNameJson.query + newSourceSubName.attribute = subNameJson.attribute ?? "text" + newSourceSubName.discriminator = subNameJson.discriminator + newSourceSubName.regex = subNameJson.regex + + newSourceRssParser.subName = newSourceSubName + } + + if let titleJson = rssParserJson.title { + let newSourceTitle = SourceTitle(context: backgroundContext) + newSourceTitle.query = titleJson.query + newSourceTitle.attribute = titleJson.attribute ?? "text" + newSourceTitle.discriminator = titleJson.discriminator + newSourceTitle.regex = titleJson.regex + + newSourceRssParser.title = newSourceTitle + } + + if let sizeJson = rssParserJson.size { + let newSourceSize = SourceSize(context: backgroundContext) + newSourceSize.query = sizeJson.query + newSourceSize.attribute = sizeJson.attribute ?? "text" + newSourceSize.discriminator = sizeJson.discriminator + newSourceSize.regex = sizeJson.regex + + newSourceRssParser.size = newSourceSize + } + + if let seedLeechJson = rssParserJson.sl { + let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext) + newSourceSeedLeech.seeders = seedLeechJson.seeders + newSourceSeedLeech.leechers = seedLeechJson.leechers + newSourceSeedLeech.combined = seedLeechJson.combined + newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text" + newSourceSeedLeech.discriminator = seedLeechJson.discriminator + newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex + newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex + + newSourceRssParser.seedLeech = newSourceSeedLeech + } + + newSource.rssParser = newSourceRssParser + } + + func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) { + let backgroundContext = PersistenceController.shared.backgroundContext + + let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext) + newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl + newSourceHtmlParser.rows = htmlParserJson.rows + + if let subNameJson = htmlParserJson.subName { + let newSourceSubName = SourceSubName(context: backgroundContext) + newSourceSubName.query = subNameJson.query + newSourceSubName.attribute = subNameJson.attribute ?? "text" + newSourceSubName.regex = subNameJson.regex + + newSourceHtmlParser.subName = newSourceSubName + } + + // Adds a title complex query if present + if let titleJson = htmlParserJson.title { + let newSourceTitle = SourceTitle(context: backgroundContext) + newSourceTitle.query = titleJson.query + newSourceTitle.attribute = titleJson.attribute ?? "text" + newSourceTitle.regex = titleJson.regex + + newSourceHtmlParser.title = newSourceTitle + } + + // Adds a size complex query if present + if let sizeJson = htmlParserJson.size { + let newSourceSize = SourceSize(context: backgroundContext) + newSourceSize.query = sizeJson.query + newSourceSize.attribute = sizeJson.attribute ?? "text" + newSourceSize.regex = sizeJson.regex + + newSourceHtmlParser.size = newSourceSize + } + + if let seedLeechJson = htmlParserJson.sl { + let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext) + newSourceSeedLeech.seeders = seedLeechJson.seeders + newSourceSeedLeech.leechers = seedLeechJson.leechers + newSourceSeedLeech.combined = seedLeechJson.combined + newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text" + newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex + newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex + + newSourceHtmlParser.seedLeech = newSourceSeedLeech + } + + // Adds a magnet complex query and its unique properties + let newSourceMagnet = SourceMagnetLink(context: backgroundContext) + newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery + newSourceMagnet.query = htmlParserJson.magnet.query + newSourceMagnet.attribute = htmlParserJson.magnet.attribute + newSourceMagnet.regex = htmlParserJson.magnet.regex + + newSourceHtmlParser.magnetLink = newSourceMagnet + + newSource.htmlParser = newSourceHtmlParser + } + + // 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 url.isEmpty || URL(string: url) == nil { + throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.") + } + + let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: url)!)) + let rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data) + + if let existingPluginList { + existingPluginList.urlString = url + existingPluginList.name = rawResponse.name + existingPluginList.author = rawResponse.author + + 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 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.") + } + + let newPluginList = PluginList(context: backgroundContext) + newPluginList.id = UUID() + newPluginList.urlString = url + newPluginList.name = rawResponse.name + newPluginList.author = rawResponse.author + + try backgroundContext.save() + } + } +} diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index e1fdab2..9aff244 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -18,13 +18,23 @@ class ScrapingViewModel: ObservableObject { var runningSearchTask: Task? @Published var searchResults: [SearchResult] = [] - @Published var searchText: String = "" @Published var filteredSource: Source? @Published var currentSourceName: String? + // Only add results with valid magnet hashes to the search results array @MainActor func updateSearchResults(newResults: [SearchResult]) { - searchResults = newResults + searchResults += newResults.filter { $0.magnet.hash != nil } + } + + @MainActor + func clearSearchResults() { + searchResults = [] + } + + func cancelCurrentTask() { + runningSearchTask?.cancel() + runningSearchTask = nil } // Utility function to print source specific errors @@ -38,7 +48,7 @@ class ScrapingViewModel: ObservableObject { print(newDescription) } - public func scanSources(sources: [Source]) async { + public func scanSources(sources: [Source], searchText: String) async { if sources.isEmpty { await toastModel?.updateToastDescription("There are no sources to search!", newToastType: .info) @@ -46,13 +56,20 @@ class ScrapingViewModel: ObservableObject { return } - var tempResults: [SearchResult] = [] + await clearSearchResults() + + await toastModel?.updateIndeterminateToast("Loading", cancelAction: { + self.cancelCurrentTask() + }) for source in sources { + // If the search is cancelled, return + if let runningSearchTask, runningSearchTask.isCancelled { + return + } + if source.enabled { - Task { @MainActor in - currentSourceName = source.name - } + await toastModel?.updateIndeterminateToast("Loading \(source.name)", cancelAction: nil) guard let baseUrl = source.baseUrl else { await toastModel?.updateToastDescription("The base URL could not be found for source \(source.name)") @@ -86,7 +103,7 @@ class ScrapingViewModel: ObservableObject { let html = String(data: data, encoding: .utf8) { let sourceResults = await scrapeHtml(source: source, baseUrl: baseUrl, html: html) - tempResults += sourceResults + await updateSearchResults(newResults: sourceResults) } } case .rss: @@ -111,7 +128,7 @@ class ScrapingViewModel: ObservableObject { let rss = String(data: data, encoding: .utf8) { let sourceResults = await scrapeRss(source: source, rss: rss) - tempResults += sourceResults + await updateSearchResults(newResults: sourceResults) } } case .siteApi: @@ -155,7 +172,7 @@ class ScrapingViewModel: ObservableObject { if let data { let sourceResults = await scrapeJson(source: source, jsonData: data) - tempResults += sourceResults + await updateSearchResults(newResults: sourceResults) } } case .none: @@ -164,12 +181,10 @@ class ScrapingViewModel: ObservableObject { } } - // If the task is cancelled, return + // If the search is cancelled, return if let searchTask = runningSearchTask, searchTask.isCancelled { return } - - await updateSearchResults(newResults: tempResults) } // Checks the base URL for any website data then iterates through the fallback URLs @@ -399,6 +414,12 @@ class ScrapingViewModel: ObservableObject { } } + var subName: String? + if let subNameParser = jsonParser.subName { + let rawSubName = result[subNameParser.query.components(separatedBy: ".")].rawValue + subName = rawSubName is NSNull ? nil : String(describing: rawSubName) + } + var link: String? = existingSearchResult?.magnet.link if let magnetLinkParser = jsonParser.magnetLink, link == nil { let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue @@ -432,7 +453,7 @@ class ScrapingViewModel: ObservableObject { let result = SearchResult( title: title, - source: source.name, + source: subName.map { "\(source.name) - \($0)" } ?? source.name, size: size, magnet: Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers), seeders: seeders, @@ -462,6 +483,7 @@ class ScrapingViewModel: ObservableObject { } for item in items { + //print(item) // Parse magnet link or translate hash var magnetHash: String? if let magnetHashParser = rssParser.magnetHash { @@ -474,6 +496,18 @@ class ScrapingViewModel: ObservableObject { ) } + // Fetches the subName for the source if there is one + var subName: String? + if let subNameParser = rssParser.subName { + subName = try? runRssComplexQuery( + item: item, + query: subNameParser.query, + attribute: subNameParser.attribute, + discriminator: subNameParser.discriminator, + regexString: subNameParser.regex + ) + } + var title: String? if let titleParser = rssParser.title { title = try? runRssComplexQuery( @@ -485,21 +519,15 @@ class ScrapingViewModel: ObservableObject { ) } - var link: String? + var href: String? if let magnetLinkParser = rssParser.magnetLink { - link = try? runRssComplexQuery( + href = try? runRssComplexQuery( item: item, query: magnetLinkParser.query, attribute: magnetLinkParser.attribute, discriminator: magnetLinkParser.discriminator, regexString: magnetLinkParser.regex ) - } else { - continue - } - - guard let href = link, href.starts(with: "magnet:") else { - continue } var size: String? @@ -543,7 +571,7 @@ class ScrapingViewModel: ObservableObject { let result = SearchResult( title: title ?? "No title", - source: source.name, + source: subName.map { "\(source.name) - \($0)" } ?? source.name, size: size ?? "", magnet: Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers), seeders: seeders, @@ -649,10 +677,6 @@ class ScrapingViewModel: ObservableObject { href = link } - if !href.starts(with: "magnet:") { - continue - } - // Fetches the episode/movie title var title: String? if let titleParser = htmlParser.title { @@ -664,8 +688,17 @@ class ScrapingViewModel: ObservableObject { ) } - // Fetches the torrent's size - // TODO: Add int translation + var subName: String? + if let subNameParser = htmlParser.subName { + subName = try? runHtmlComplexQuery( + row: row, + query: subNameParser.query, + attribute: subNameParser.attribute, + regexString: subNameParser.regex + ) + } + + // Fetches the size var size: String? if let sizeParser = htmlParser.size { size = try? runHtmlComplexQuery( @@ -718,7 +751,7 @@ class ScrapingViewModel: ObservableObject { let result = SearchResult( title: title ?? "No title", - source: source.name, + source: subName.map { "\(source.name) - \($0)" } ?? source.name, size: size ?? "", magnet: Magnet(hash: nil, link: href), seeders: seeders, diff --git a/Ferrite/ViewModels/SourceManager.swift b/Ferrite/ViewModels/SourceManager.swift deleted file mode 100644 index de8989f..0000000 --- a/Ferrite/ViewModels/SourceManager.swift +++ /dev/null @@ -1,426 +0,0 @@ -// -// SourceManager.swift -// Ferrite -// -// Created by Brian Dashore on 7/25/22. -// - -import CoreData -import Foundation -import SwiftUI - -public class SourceManager: ObservableObject { - var toastModel: ToastViewModel? - - @Published var availableSources: [SourceJson] = [] - - var urlErrorAlertText = "" - @Published var showUrlErrorAlert = false - - @MainActor - public func fetchSourcesFromUrl() async { - let sourceListRequest = SourceList.fetchRequest() - do { - let sourceLists = try PersistenceController.shared.backgroundContext.fetch(sourceListRequest) - var tempAvailableSources: [SourceJson] = [] - - for sourceList in sourceLists { - guard let url = URL(string: sourceList.urlString) else { - return - } - - // Always get the up-to-date source list - 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) - - 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 - - tempAvailableSources.append(source) - } - } - } - - availableSources = tempAvailableSources - } catch { - print(error) - } - } - - func fetchUpdatedSources(installedSources: FetchedResults) -> [SourceJson] { - var updatedSources: [SourceJson] = [] - - for source in installedSources { - if let availableSource = availableSources.first(where: { - source.listId == $0.listId && source.name == $0.name && source.author == $0.author - }), - availableSource.version > source.version - { - updatedSources.append(availableSource) - } - } - - return updatedSources - } - - // Checks if the current app version is supported by the source - func checkAppVersion(minVersion: String?) -> Bool { - // If there's no min version, assume that every version is supported - guard let minVersion else { - return true - } - - return Application.shared.appVersion >= minVersion - } - - // Fetches sources using the background context - public func fetchInstalledSources() -> [Source] { - let backgroundContext = PersistenceController.shared.backgroundContext - - if let sources = try? backgroundContext.fetch(Source.fetchRequest()) { - return sources.compactMap { $0 } - } else { - return [] - } - } - - public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) async { - let backgroundContext = PersistenceController.shared.backgroundContext - - // If there's no base URL and it isn't dynamic, return before any transactions occur - let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false - - 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") - return - } - - // If a source exists, don't add the new one unless upserting - let existingSourceRequest = Source.fetchRequest() - existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name) - existingSourceRequest.fetchLimit = 1 - - if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first { - if doUpsert { - PersistenceController.shared.delete(existingSource, context: backgroundContext) - } else { - await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.") - return - } - } - - let newSource = Source(context: backgroundContext) - newSource.id = UUID() - newSource.name = sourceJson.name - newSource.version = sourceJson.version - newSource.dynamicBaseUrl = dynamicBaseUrl - newSource.baseUrl = sourceJson.baseUrl - newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls - newSource.author = sourceJson.author ?? "Unknown" - newSource.listId = sourceJson.listId - newSource.trackers = sourceJson.trackers - - if let sourceApiJson = sourceJson.api { - addSourceApi(newSource: newSource, apiJson: sourceApiJson) - } - - if let jsonParserJson = sourceJson.jsonParser { - addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson) - } - - // Adds an RSS parser if present - if let rssParserJson = sourceJson.rssParser { - addRssParser(newSource: newSource, rssParserJson: rssParserJson) - } - - // Adds an HTML parser if present - if let htmlParserJson = sourceJson.htmlParser { - addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson) - } - - // Add an API condition as well - if newSource.jsonParser != nil { - newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue) - } else if newSource.rssParser != nil { - newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue) - } else { - newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue) - } - - newSource.enabled = true - - do { - try backgroundContext.save() - } catch { - await toastModel?.updateToastDescription(error.localizedDescription) - } - } - - func addSourceApi(newSource: Source, apiJson: SourceApiJson) { - let backgroundContext = PersistenceController.shared.backgroundContext - - let newSourceApi = SourceApi(context: backgroundContext) - newSourceApi.apiUrl = apiJson.apiUrl - - if let clientIdJson = apiJson.clientId { - let newClientId = SourceApiClientId(context: backgroundContext) - newClientId.query = clientIdJson.query - newClientId.urlString = clientIdJson.url - newClientId.dynamic = clientIdJson.dynamic ?? false - newClientId.value = clientIdJson.value - newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue - newClientId.expiryLength = clientIdJson.expiryLength ?? 0 - newClientId.timeStamp = Date() - - newSourceApi.clientId = newClientId - } - - if let clientSecretJson = apiJson.clientSecret { - let newClientSecret = SourceApiClientSecret(context: backgroundContext) - newClientSecret.query = clientSecretJson.query - newClientSecret.urlString = clientSecretJson.url - newClientSecret.dynamic = clientSecretJson.dynamic ?? false - newClientSecret.value = clientSecretJson.value - newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue - newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0 - newClientSecret.timeStamp = Date() - - newSourceApi.clientSecret = newClientSecret - } - - newSource.api = newSourceApi - } - - func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) { - let backgroundContext = PersistenceController.shared.backgroundContext - - let newSourceJsonParser = SourceJsonParser(context: backgroundContext) - newSourceJsonParser.searchUrl = jsonParserJson.searchUrl - newSourceJsonParser.results = jsonParserJson.results - newSourceJsonParser.subResults = jsonParserJson.subResults - - // Tune these complex queries to the final JSON parser format - if let magnetLinkJson = jsonParserJson.magnetLink { - let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) - newSourceMagnetLink.query = magnetLinkJson.query - newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text" - newSourceMagnetLink.discriminator = magnetLinkJson.discriminator - - newSourceJsonParser.magnetLink = newSourceMagnetLink - } - - if let magnetHashJson = jsonParserJson.magnetHash { - let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext) - newSourceMagnetHash.query = magnetHashJson.query - newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text" - newSourceMagnetHash.discriminator = magnetHashJson.discriminator - - newSourceJsonParser.magnetHash = newSourceMagnetHash - } - - if let titleJson = jsonParserJson.title { - let newSourceTitle = SourceTitle(context: backgroundContext) - newSourceTitle.query = titleJson.query - newSourceTitle.attribute = titleJson.attribute ?? "text" - newSourceTitle.discriminator = titleJson.discriminator - - newSourceJsonParser.title = newSourceTitle - } - - if let sizeJson = jsonParserJson.size { - let newSourceSize = SourceSize(context: backgroundContext) - newSourceSize.query = sizeJson.query - newSourceSize.attribute = sizeJson.attribute ?? "text" - newSourceSize.discriminator = sizeJson.discriminator - - newSourceJsonParser.size = newSourceSize - } - - if let seedLeechJson = jsonParserJson.sl { - let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext) - newSourceSeedLeech.seeders = seedLeechJson.seeders - newSourceSeedLeech.leechers = seedLeechJson.leechers - newSourceSeedLeech.combined = seedLeechJson.combined - newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text" - newSourceSeedLeech.discriminator = seedLeechJson.discriminator - newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex - newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex - - newSourceJsonParser.seedLeech = newSourceSeedLeech - } - - newSource.jsonParser = newSourceJsonParser - } - - func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) { - let backgroundContext = PersistenceController.shared.backgroundContext - - let newSourceRssParser = SourceRssParser(context: backgroundContext) - newSourceRssParser.rssUrl = rssParserJson.rssUrl - newSourceRssParser.searchUrl = rssParserJson.searchUrl - newSourceRssParser.items = rssParserJson.items - - if let magnetLinkJson = rssParserJson.magnetLink { - let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) - newSourceMagnetLink.query = magnetLinkJson.query - newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text" - newSourceMagnetLink.discriminator = magnetLinkJson.discriminator - - newSourceRssParser.magnetLink = newSourceMagnetLink - } - - if let magnetHashJson = rssParserJson.magnetHash { - let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext) - newSourceMagnetHash.query = magnetHashJson.query - newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text" - newSourceMagnetHash.discriminator = magnetHashJson.discriminator - - newSourceRssParser.magnetHash = newSourceMagnetHash - } - - if let titleJson = rssParserJson.title { - let newSourceTitle = SourceTitle(context: backgroundContext) - newSourceTitle.query = titleJson.query - newSourceTitle.attribute = titleJson.attribute ?? "text" - newSourceTitle.discriminator = titleJson.discriminator - - newSourceRssParser.title = newSourceTitle - } - - if let sizeJson = rssParserJson.size { - let newSourceSize = SourceSize(context: backgroundContext) - newSourceSize.query = sizeJson.query - newSourceSize.attribute = sizeJson.attribute ?? "text" - newSourceSize.discriminator = sizeJson.discriminator - - newSourceRssParser.size = newSourceSize - } - - if let seedLeechJson = rssParserJson.sl { - let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext) - newSourceSeedLeech.seeders = seedLeechJson.seeders - newSourceSeedLeech.leechers = seedLeechJson.leechers - newSourceSeedLeech.combined = seedLeechJson.combined - newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text" - newSourceSeedLeech.discriminator = seedLeechJson.discriminator - newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex - newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex - - newSourceRssParser.seedLeech = newSourceSeedLeech - } - - newSource.rssParser = newSourceRssParser - } - - func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) { - let backgroundContext = PersistenceController.shared.backgroundContext - - let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext) - newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl - newSourceHtmlParser.rows = htmlParserJson.rows - - // Adds a title complex query if present - if let titleJson = htmlParserJson.title { - let newSourceTitle = SourceTitle(context: backgroundContext) - newSourceTitle.query = titleJson.query - newSourceTitle.attribute = titleJson.attribute ?? "text" - newSourceTitle.regex = titleJson.regex - - newSourceHtmlParser.title = newSourceTitle - } - - // Adds a size complex query if present - if let sizeJson = htmlParserJson.size { - let newSourceSize = SourceSize(context: backgroundContext) - newSourceSize.query = sizeJson.query - newSourceSize.attribute = sizeJson.attribute ?? "text" - newSourceSize.regex = sizeJson.regex - - newSourceHtmlParser.size = newSourceSize - } - - if let seedLeechJson = htmlParserJson.sl { - let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext) - newSourceSeedLeech.seeders = seedLeechJson.seeders - newSourceSeedLeech.leechers = seedLeechJson.leechers - newSourceSeedLeech.combined = seedLeechJson.combined - newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text" - newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex - newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex - - newSourceHtmlParser.seedLeech = newSourceSeedLeech - } - - // Adds a magnet complex query and its unique properties - let newSourceMagnet = SourceMagnetLink(context: backgroundContext) - newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery - newSourceMagnet.query = htmlParserJson.magnet.query - newSourceMagnet.attribute = htmlParserJson.magnet.attribute - newSourceMagnet.regex = htmlParserJson.magnet.regex - - newSourceHtmlParser.magnetLink = newSourceMagnet - - newSource.htmlParser = newSourceHtmlParser - } - - @MainActor - public func addSourceList(sourceUrl: String, existingSourceList: SourceList?) async -> Bool { - 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 - } - - do { - let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!)) - let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data) - - if let existingSourceList { - existingSourceList.urlString = sourceUrl - existingSourceList.name = rawResponse.name - existingSourceList.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 - - 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() - } - - return true - } catch { - print(error) - urlErrorAlertText = error.localizedDescription - showUrlErrorAlert.toggle() - - return false - } - } -} diff --git a/Ferrite/ViewModels/ToastViewModel.swift b/Ferrite/ViewModels/ToastViewModel.swift index d3f1704..28045a4 100644 --- a/Ferrite/ViewModels/ToastViewModel.swift +++ b/Ferrite/ViewModels/ToastViewModel.swift @@ -35,6 +35,10 @@ class ToastViewModel: ObservableObject { @Published var showToast: Bool = false + @Published var indeterminateToastDescription: String? = nil + @Published var indeterminateCancelAction: (() -> ())? = nil + @Published var showIndeterminateToast: Bool = false + public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) { if let newToastType { toastType = newToastType @@ -43,6 +47,24 @@ class ToastViewModel: ObservableObject { toastDescription = description } + public func updateIndeterminateToast(_ description: String, cancelAction: (() -> ())?) { + indeterminateToastDescription = description + + if let cancelAction { + indeterminateCancelAction = cancelAction + } + + if !showIndeterminateToast { + showIndeterminateToast = true + } + } + + public func hideIndeterminateToast() { + showIndeterminateToast = false + indeterminateToastDescription = "" + indeterminateCancelAction = nil + } + // Default the toast type to error since the majority of toasts are errors @Published var toastType: ToastType = .error } diff --git a/Ferrite/Views/CommonViews/AlertButton.swift b/Ferrite/Views/CommonViews/AlertButton.swift index 664317a..e572879 100644 --- a/Ferrite/Views/CommonViews/AlertButton.swift +++ b/Ferrite/Views/CommonViews/AlertButton.swift @@ -29,9 +29,9 @@ struct AlertButton: Identifiable { } // Used for buttons with no action - init(_ label: String = "Cancel", role: Role? = nil) { + init(_ label: String? = nil, role: Role? = nil) { id = UUID() - self.label = label + self.label = label ?? (role == .cancel ? "Cancel" : "OK") action = {} self.role = role } diff --git a/Ferrite/Views/CommonViews/Backport.swift b/Ferrite/Views/CommonViews/Backport.swift index d57643a..2c7221d 100644 --- a/Ferrite/Views/CommonViews/Backport.swift +++ b/Ferrite/Views/CommonViews/Backport.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Introspect public struct Backport { public let content: Content @@ -20,7 +21,12 @@ extension View { } extension Backport where Content: View { - @ViewBuilder func alert(isPresented: Binding, title: String, message: String?, buttons: [AlertButton]) -> some View { + @ViewBuilder func alert( + isPresented: Binding, + title: String, + message: String?, + buttons: [AlertButton] = [] + ) -> some View { if #available(iOS 15, *) { content .alert( @@ -55,7 +61,7 @@ extension Backport where Content: View { return Alert( title: Text(title), message: message.map { Text($0) } ?? nil, - dismissButton: buttons[0].toActionButton() + dismissButton: buttons[safe: 0].map { $0.toActionButton() } ?? .cancel() ) } } @@ -63,7 +69,11 @@ extension Backport where Content: View { } } - @ViewBuilder func confirmationDialog(isPresented: Binding, title: String, message: String?, buttons: [AlertButton]) -> some View { + @ViewBuilder func confirmationDialog( + isPresented: Binding, + title: String, message: String?, + buttons: [AlertButton] + ) -> some View { if #available(iOS 15, *) { content .confirmationDialog( @@ -100,4 +110,31 @@ extension Backport where Content: View { .accentColor(color) } } + + @ViewBuilder func onAppear(callback: @escaping () -> Void) -> some View { + if #available(iOS 15, *) { + content + .onAppear { + callback() + } + } else { + content + .viewDidAppear { + callback() + } + } + } + + @ViewBuilder func introspectSearchController(customize: @escaping (UISearchController) -> ()) -> some View { + if #available(iOS 15, *) { + content.introspectSearchController(customize: customize) + } else { + content.introspectNavigationController { navigationController in + let navigationBar = navigationController.navigationBar + if let searchController = navigationBar.topItem?.searchController { + customize(searchController) + } + } + } + } } diff --git a/Ferrite/Views/CommonViews/DynamicFetchRequest.swift b/Ferrite/Views/CommonViews/DynamicFetchRequest.swift index 145addb..dafbbfc 100644 --- a/Ferrite/Views/CommonViews/DynamicFetchRequest.swift +++ b/Ferrite/Views/CommonViews/DynamicFetchRequest.swift @@ -21,9 +21,10 @@ struct DynamicFetchRequest: View { } init(predicate: NSPredicate?, + sortDescriptors: [NSSortDescriptor] = [], @ViewBuilder content: @escaping (FetchedResults) -> Content) { - _fetchRequest = FetchRequest(sortDescriptors: [], predicate: predicate) + _fetchRequest = FetchRequest(entity: T.entity(), sortDescriptors: sortDescriptors, predicate: predicate) self.content = content } } diff --git a/Ferrite/Views/CommonViews/FilterLabelView.swift b/Ferrite/Views/CommonViews/FilterLabelView.swift new file mode 100644 index 0000000..deb36a2 --- /dev/null +++ b/Ferrite/Views/CommonViews/FilterLabelView.swift @@ -0,0 +1,27 @@ +// +// FilterLabelView.swift +// Ferrite +// +// Created by Brian Dashore on 2/12/23. +// + +import SwiftUI + +struct FilterLabelView: View { + var name: String + + var body: some View { + HStack(spacing: 4) { + Text(name) + .opacity(0.6) + .foregroundColor(.primary) + + Image(systemName: "chevron.down") + .foregroundColor(.tertiaryLabel) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .font(.caption, weight: .medium) + .background(Capsule().foregroundColor(.secondarySystemFill)) + } +} diff --git a/Ferrite/Views/CommonViews/IndeterminateProgressView.swift b/Ferrite/Views/CommonViews/IndeterminateProgressView.swift index c9f6e33..2ce666f 100644 --- a/Ferrite/Views/CommonViews/IndeterminateProgressView.swift +++ b/Ferrite/Views/CommonViews/IndeterminateProgressView.swift @@ -25,7 +25,7 @@ struct IndeterminateProgressView: View { .offset(x: -reader.size.width * 0.6, y: 0) .offset(x: reader.size.width * 1.2 * self.offset, y: 0) .animation(.default.repeatForever().speed(0.5), value: self.offset) - .onAppear { + .backport.onAppear { withAnimation { self.offset = 1 } diff --git a/Ferrite/Views/CommonViews/LibraryHeaderView.swift b/Ferrite/Views/CommonViews/LibraryHeaderView.swift new file mode 100644 index 0000000..e6009e1 --- /dev/null +++ b/Ferrite/Views/CommonViews/LibraryHeaderView.swift @@ -0,0 +1,17 @@ +// +// LibraryHeaderView.swift +// Ferrite +// +// Created by Brian Dashore on 2/12/23. +// + +import SwiftUI + +struct LibraryHeaderView: View { + @EnvironmentObject var debridManager: DebridManager + + @Binding var selectedSegment: LibraryPickerSegment + var body: some View { + + } +} diff --git a/Ferrite/Views/CommonViews/Modifiers/ConditionalContextMenu.swift b/Ferrite/Views/CommonViews/Modifiers/ConditionalContextMenu.swift index d981673..c18f3de 100644 --- a/Ferrite/Views/CommonViews/Modifiers/ConditionalContextMenu.swift +++ b/Ferrite/Views/CommonViews/Modifiers/ConditionalContextMenu.swift @@ -10,7 +10,7 @@ import SwiftUI -struct ConditionalContextMenu: ViewModifier { +struct ConditionalContextMenuModifier: ViewModifier { let internalContent: () -> InternalContent let id: ID diff --git a/Ferrite/Views/CommonViews/Modifiers/ConditionalId.swift b/Ferrite/Views/CommonViews/Modifiers/ConditionalId.swift index 146f87f..d9d89d6 100644 --- a/Ferrite/Views/CommonViews/Modifiers/ConditionalId.swift +++ b/Ferrite/Views/CommonViews/Modifiers/ConditionalId.swift @@ -10,7 +10,7 @@ import SwiftUI -struct ConditionalId: ViewModifier { +struct ConditionalIdModifier: ViewModifier { let id: ID func body(content: Content) -> some View { diff --git a/Ferrite/Views/CommonViews/Modifiers/CustomScopeBar.swift b/Ferrite/Views/CommonViews/Modifiers/CustomScopeBar.swift new file mode 100644 index 0000000..1cb11b1 --- /dev/null +++ b/Ferrite/Views/CommonViews/Modifiers/CustomScopeBar.swift @@ -0,0 +1,68 @@ +// +// SearchAppearance.swift +// Ferrite +// +// Created by Brian Dashore on 2/14/23. +// + +import SwiftUI +import Introspect + +struct CustomScopeBarModifier: ViewModifier { + let hostingContent: V + @State private var hostingController: UIHostingController? + + // Don't use AppStorage since it causes a view update + var autocorrectSearch: Bool { + UserDefaults.standard.bool(forKey: "Behavior.AutocorrectSearch") + } + + func body(content: Content) -> some View { + if #available(iOS 15, *) { + content + .backport.introspectSearchController { searchController in + searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no + searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none + + // MARK: One-time setup + guard hostingController == nil else { return } + + searchController.hidesNavigationBarDuringPresentation = true + searchController.searchBar.showsScopeBar = true + searchController.searchBar.scopeButtonTitles = [""] + (searchController.searchBar.value(forKey: "_scopeBar") as? UIView)?.isHidden = true + + let hostingController = UIHostingController(rootView: hostingContent) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.view.backgroundColor = .clear + + guard let containerView = searchController.searchBar.value(forKey: "_scopeBarContainerView") as? UIView else { + return + } + containerView.addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.widthAnchor.constraint(equalTo: containerView.widthAnchor), + hostingController.view.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingController.view.heightAnchor.constraint(equalTo: containerView.heightAnchor) + ]) + + self.hostingController = hostingController + } + .introspectNavigationController { navigationController in + navigationController.navigationBar.prefersLargeTitles = true + navigationController.navigationBar.sizeToFit() + } + } else { + VStack { + hostingContent + content + Spacer() + } + .backport.introspectSearchController { searchController in + searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no + searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none + } + } + } +} diff --git a/Ferrite/Views/CommonViews/Modifiers/DisableInteraction.swift b/Ferrite/Views/CommonViews/Modifiers/DisableInteraction.swift index 97d428c..1dc3220 100644 --- a/Ferrite/Views/CommonViews/Modifiers/DisableInteraction.swift +++ b/Ferrite/Views/CommonViews/Modifiers/DisableInteraction.swift @@ -9,7 +9,7 @@ import SwiftUI -struct DisableInteraction: ViewModifier { +struct DisableInteractionModifier: ViewModifier { let disabled: Bool func body(content: Content) -> some View { diff --git a/Ferrite/Views/CommonViews/Modifiers/DisabledAppearance.swift b/Ferrite/Views/CommonViews/Modifiers/DisabledAppearance.swift index 1caeaf6..a0f8eb8 100644 --- a/Ferrite/Views/CommonViews/Modifiers/DisabledAppearance.swift +++ b/Ferrite/Views/CommonViews/Modifiers/DisabledAppearance.swift @@ -9,7 +9,7 @@ import SwiftUI -struct DisabledAppearance: ViewModifier { +struct DisabledAppearanceModifier: ViewModifier { let disabled: Bool let dimmedOpacity: Double? let animation: Animation? diff --git a/Ferrite/Views/CommonViews/Modifiers/InlinedList.swift b/Ferrite/Views/CommonViews/Modifiers/InlinedList.swift index 3e2cac2..db8fb46 100644 --- a/Ferrite/Views/CommonViews/Modifiers/InlinedList.swift +++ b/Ferrite/Views/CommonViews/Modifiers/InlinedList.swift @@ -11,17 +11,19 @@ import Introspect import SwiftUI -struct InlinedList: ViewModifier { +struct InlinedListModifier: ViewModifier { + let inset: CGFloat + func body(content: Content) -> some View { if #available(iOS 16, *) { content .introspectCollectionView { collectionView in - collectionView.contentInset.top = -20 + collectionView.contentInset.top = inset } } else { content .introspectTableView { tableView in - tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 20)) + tableView.contentInset.top = inset } } } diff --git a/Ferrite/Views/CommonViews/Modifiers/ViewDidAppear.swift b/Ferrite/Views/CommonViews/Modifiers/ViewDidAppear.swift new file mode 100644 index 0000000..80524c8 --- /dev/null +++ b/Ferrite/Views/CommonViews/Modifiers/ViewDidAppear.swift @@ -0,0 +1,17 @@ +// +// ViewDidAppearModifier.swift +// Ferrite +// +// Created by Brian Dashore on 2/8/23. +// + +import SwiftUI + +struct ViewDidAppearModifier: ViewModifier { + let callback: () -> Void + + func body(content: Content) -> some View { + content + .background(ViewDidAppearHandler(callback: callback)) + } +} diff --git a/Ferrite/Views/CommonViews/NavView.swift b/Ferrite/Views/CommonViews/NavView.swift index b89b95f..32077f7 100644 --- a/Ferrite/Views/CommonViews/NavView.swift +++ b/Ferrite/Views/CommonViews/NavView.swift @@ -11,19 +11,16 @@ import SwiftUI struct NavView: 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) } diff --git a/Ferrite/Views/CommonViews/SearchableContent.swift b/Ferrite/Views/CommonViews/SearchableContent.swift new file mode 100644 index 0000000..754fa3a --- /dev/null +++ b/Ferrite/Views/CommonViews/SearchableContent.swift @@ -0,0 +1,37 @@ +// +// SearchableContent.swift +// Ferrite +// +// Created by Brian Dashore on 2/11/23. +// +// View to link animations together with searchbar +// Passes through geometry proxy and last height vars for any comparison +// + +import SwiftUI + +struct SearchableContent: View { + @Binding var searching: Bool + + @State private var lastHeight: CGFloat = 0.0 + + @ViewBuilder var content: (Bool) -> Content + + var body: some View { + GeometryReader { geom in + // Return if the height has changed as a closure variable for child transactions + content(geom.size.height != lastHeight) + .backport.onAppear { + lastHeight = geom.size.height + } + .onChange(of: geom.size.height) { newHeight in + lastHeight = newHeight + } + .transaction { + if geom.size.height != lastHeight && searching { + $0.animation = .default.speed(2) + } + } + } + } +} diff --git a/Ferrite/Views/CommonViews/SectionHeaderView.swift b/Ferrite/Views/CommonViews/SectionHeaderView.swift new file mode 100644 index 0000000..c46f60e --- /dev/null +++ b/Ferrite/Views/CommonViews/SectionHeaderView.swift @@ -0,0 +1,20 @@ +// +// SectionHeaderView.swift +// Ferrite +// +// Created by Brian Dashore on 2/15/23. +// + +import SwiftUI + +struct SectionHeaderView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +struct SectionHeaderView_Previews: PreviewProvider { + static var previews: some View { + SectionHeaderView() + } +} diff --git a/Ferrite/Views/CommonViews/Tag.swift b/Ferrite/Views/CommonViews/Tag.swift new file mode 100644 index 0000000..7d3b218 --- /dev/null +++ b/Ferrite/Views/CommonViews/Tag.swift @@ -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) + ) + } +} diff --git a/Ferrite/Views/CommonViews/TestHostingView.swift b/Ferrite/Views/CommonViews/TestHostingView.swift new file mode 100644 index 0000000..ebf4ae8 --- /dev/null +++ b/Ferrite/Views/CommonViews/TestHostingView.swift @@ -0,0 +1,75 @@ +// +// TestHostingView.swift +// Ferrite +// +// Created by Brian Dashore on 2/13/23. +// + +import SwiftUI + +struct TestHostingView: View { + @State private var textName = "First" + @State private var secondTextName = "First" + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Menu { + Picker("", selection: $textName) { + Text("First").tag("First") + Text("Second").tag("Second") + Text("Third").tag("Third") + } + } label: { + HStack(spacing: 2) { + Text(textName) + .opacity(0.6) + .foregroundColor(.primary) + + Image(systemName: "chevron.down") + .foregroundColor(.tertiaryLabel) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .font(.caption, weight: .bold) + .background(Capsule().foregroundColor(.secondarySystemFill)) + } + .id(textName) + .transaction { + $0.animation = .none + } + + Menu { + Picker("", selection: $secondTextName) { + Text("First").tag("First") + Text("Second").tag("Second") + Text("Third").tag("Third") + } + } label: { + HStack(spacing: 2) { + Text(secondTextName) + .opacity(0.6) + .foregroundColor(.primary) + + Image(systemName: "chevron.down") + .foregroundColor(.tertiaryLabel) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .font(.caption, weight: .bold) + .background(Capsule().foregroundColor(.secondarySystemFill)) + } + .id(secondTextName) + .transaction { + $0.animation = .none + } + } + .padding(.horizontal, 18) + } + } +} + +struct TestHostingView_Previews: PreviewProvider { + static var previews: some View { + TestHostingView() + } +} diff --git a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift index e6134d7..e3fd2b0 100644 --- a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift +++ b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift @@ -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 } } } diff --git a/Ferrite/Views/ComponentViews/Debrid/DebridChoiceView.swift b/Ferrite/Views/ComponentViews/Debrid/DebridPickerView.swift similarity index 72% rename from Ferrite/Views/ComponentViews/Debrid/DebridChoiceView.swift rename to Ferrite/Views/ComponentViews/Debrid/DebridPickerView.swift index f38ddf5..6aaf7f8 100644 --- a/Ferrite/Views/ComponentViews/Debrid/DebridChoiceView.swift +++ b/Ferrite/Views/ComponentViews/Debrid/DebridPickerView.swift @@ -7,15 +7,17 @@ import SwiftUI -struct DebridChoiceView: View { +struct DebridPickerView: View { @EnvironmentObject var debridManager: DebridManager + @ViewBuilder var label: Content + var body: some View { Menu { Picker("", selection: $debridManager.selectedDebridType) { Text("None") .tag(nil as DebridType?) - + ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in if debridManager.enabledDebrids.contains(debridType) { Text(debridType.toString()) @@ -24,14 +26,7 @@ struct DebridChoiceView: View { } } } label: { - Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid") + label } - .animation(.none) - } -} - -struct DebridChoiceView_Previews: PreviewProvider { - static var previews: some View { - DebridChoiceView() } } diff --git a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift index f2be3c7..7668409 100644 --- a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -21,37 +21,39 @@ struct BookmarksView: View { @State private var bookmarkPredicate: NSPredicate? var body: some View { - DynamicFetchRequest(predicate: bookmarkPredicate) { (bookmarks: FetchedResults) in + DynamicFetchRequest( + predicate: bookmarkPredicate, + sortDescriptors: [NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true)] + ) { (bookmarks: FetchedResults) in List { - if !bookmarks.isEmpty { - ForEach(bookmarks, id: \.self) { bookmark in - SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark) - } - .onDelete { offsets in - for index in offsets { - if let bookmark = bookmarks[safe: index] { - PersistenceController.shared.delete(bookmark, context: backgroundContext) - - NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark) + if !bookmarks.isEmpty { + ForEach(bookmarks, id: \.self) { bookmark in + SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark) + } + .onDelete { offsets in + for index in offsets { + if let bookmark = bookmarks[safe: index] { + PersistenceController.shared.delete(bookmark, context: backgroundContext) + NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark) + } } } - } - .onMove { source, destination in - var changedBookmarks = bookmarks.map { $0 } + .onMove { source, destination in + var changedBookmarks = bookmarks.map { $0 } - changedBookmarks.move(fromOffsets: source, toOffset: destination) + changedBookmarks.move(fromOffsets: source, toOffset: destination) - for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) { - changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex) + for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) { + changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex) + } + + PersistenceController.shared.save() } - - PersistenceController.shared.save() } - } } - .inlinedList() .listStyle(.insetGrouped) - .onAppear { + .inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 15 : -25) + .backport.onAppear { if debridManager.enabledDebrids.count > 0 { viewTask = Task { let magnets = bookmarks.compactMap { @@ -69,7 +71,7 @@ struct BookmarksView: View { viewTask?.cancel() } } - .onAppear { + .backport.onAppear { applyPredicate() } .onChange(of: searchText) { _ in diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift index e76418b..fc8f0b5 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift @@ -10,6 +10,7 @@ import SwiftUI struct AllDebridCloudView: View { @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var pluginManager: PluginManager @Binding var searchText: String @@ -37,8 +38,11 @@ struct AllDebridCloudView: View { if !debridManager.downloadUrl.isEmpty { historyInfo.url = debridManager.downloadUrl - PersistenceController.shared.createHistory(historyInfo) - navModel.runDebridAction(urlString: debridManager.downloadUrl) + PersistenceController.shared.createHistory(historyInfo, performSave: true) + pluginManager.runDebridAction( + urlString: debridManager.downloadUrl, + currentChoiceSheet: &navModel.currentChoiceSheet + ) } } } else { @@ -80,7 +84,7 @@ struct AllDebridCloudView: View { } } } - .onAppear { + .backport.onAppear { viewTask = Task { await debridManager.fetchAdCloud() } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift index 96e902c..760cd10 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift @@ -11,6 +11,7 @@ import SwiftUIX struct PremiumizeCloudView: View { @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var pluginManager: PluginManager @Binding var searchText: String @@ -34,10 +35,14 @@ struct PremiumizeCloudView: View { name: item.name, url: debridManager.downloadUrl, source: DebridType.premiumize.toString() - ) + ), + performSave: true ) - navModel.runDebridAction(urlString: debridManager.downloadUrl) + pluginManager.runDebridAction( + urlString: debridManager.downloadUrl, + currentChoiceSheet: &navModel.currentChoiceSheet + ) } } } @@ -54,7 +59,7 @@ struct PremiumizeCloudView: View { } } } - .onAppear { + .backport.onAppear { viewTask = Task { await debridManager.fetchPmCloud() } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift index 9e07bc6..9b4700c 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -10,6 +10,7 @@ import SwiftUI struct RealDebridCloudView: View { @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var pluginManager: PluginManager @Binding var searchText: String @@ -31,10 +32,14 @@ struct RealDebridCloudView: View { name: downloadResponse.filename, url: downloadResponse.download, source: DebridType.realDebrid.toString() - ) + ), + performSave: true ) - navModel.runDebridAction(urlString: debridManager.downloadUrl) + pluginManager.runDebridAction( + urlString: debridManager.downloadUrl, + currentChoiceSheet: &navModel.currentChoiceSheet + ) } .backport.tint(.primary) } @@ -69,9 +74,12 @@ 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) + pluginManager.runDebridAction( + urlString: debridManager.downloadUrl, + currentChoiceSheet: &navModel.currentChoiceSheet + ) } } } else { @@ -116,7 +124,7 @@ struct RealDebridCloudView: View { } } } - .onAppear { + .backport.onAppear { viewTask = Task { await debridManager.fetchRdCloud() } diff --git a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift index 8e85859..34f793e 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift @@ -11,6 +11,7 @@ struct HistoryButtonView: View { @EnvironmentObject var toastModel: ToastViewModel @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var pluginManager: PluginManager let entry: HistoryEntry @@ -23,14 +24,20 @@ struct HistoryButtonView: View { if url.starts(with: "https://") { Task { debridManager.downloadUrl = url - navModel.runDebridAction(urlString: url) + pluginManager.runDebridAction( + urlString: url, + currentChoiceSheet: &navModel.currentChoiceSheet + ) - if navModel.currentChoiceSheet != .magnet { + if navModel.currentChoiceSheet != .action { debridManager.downloadUrl = "" } } } else { - navModel.runMagnetAction(magnet: Magnet(hash: nil, link: url)) + pluginManager.runMagnetAction( + urlString: url, + currentChoiceSheet: &navModel.currentChoiceSheet + ) } } else { toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.") @@ -77,4 +84,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 + } + } } diff --git a/Ferrite/Views/ComponentViews/Library/HistoryView.swift b/Ferrite/Views/ComponentViews/Library/HistoryView.swift index 1e7b4cc..6c7d25c 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryView.swift @@ -27,7 +27,7 @@ struct HistoryView: View { } .listStyle(.insetGrouped) } - .onAppear { + .backport.onAppear { applyPredicate() } .onChange(of: searchText) { _ in @@ -71,7 +71,7 @@ struct HistorySectionView: View { var body: some View { if compareGroup(historyGroup) > 0 { - Section(header: Text(formatter.string(from: historyGroup[0].date ?? Date()))) { + Section(header: InlineHeader(formatter.string(from: historyGroup[0].date ?? Date()))) { ForEach(historyGroup, id: \.self) { history in ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in HistoryButtonView(entry: entry) diff --git a/Ferrite/Views/ComponentViews/Library/LibraryPickerView.swift b/Ferrite/Views/ComponentViews/Library/LibraryPickerView.swift new file mode 100644 index 0000000..8c97e58 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/LibraryPickerView.swift @@ -0,0 +1,31 @@ +// +// LibraryPickerView.swift +// Ferrite +// +// Created by Brian Dashore on 2/13/23. +// + +import SwiftUI + +struct LibraryPickerView: View { + @Environment(\.verticalSizeClass) var verticalSizeClass + + @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var navModel: NavigationViewModel + + var body: some View { + HStack { + Picker("Segments", selection: $navModel.libraryPickerSelection) { + Text("Bookmarks").tag(NavigationViewModel.LibraryPickerSegment.bookmarks) + Text("History").tag(NavigationViewModel.LibraryPickerSegment.history) + + if !debridManager.enabledDebrids.isEmpty { + Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud) + } + } + } + .pickerStyle(.segmented) + .padding(.horizontal, verticalSizeClass == .compact && UIDevice.current.hasNotch ? 65 : 18) + .padding(.vertical, 5) + } +} diff --git a/Ferrite/Views/ComponentViews/Source/Buttons/InstalledSourceButtonView.swift b/Ferrite/Views/ComponentViews/Plugin/Buttons/InstalledPluginButtonView.swift similarity index 50% rename from Ferrite/Views/ComponentViews/Source/Buttons/InstalledSourceButtonView.swift rename to Ferrite/Views/ComponentViews/Plugin/Buttons/InstalledPluginButtonView.swift index 3564629..b99061d 100644 --- a/Ferrite/Views/ComponentViews/Source/Buttons/InstalledSourceButtonView.swift +++ b/Ferrite/Views/ComponentViews/Plugin/Buttons/InstalledPluginButtonView.swift @@ -7,52 +7,60 @@ import SwiftUI -struct InstalledSourceButtonView: View { +struct InstalledPluginButtonView: 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( - 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") diff --git a/Ferrite/Views/ComponentViews/Plugin/Buttons/PluginCatalogButtonView.swift b/Ferrite/Views/ComponentViews/Plugin/Buttons/PluginCatalogButtonView.swift new file mode 100644 index 0000000..a2c525b --- /dev/null +++ b/Ferrite/Views/ComponentViews/Plugin/Buttons/PluginCatalogButtonView.swift @@ -0,0 +1,51 @@ +// +// SourceCatalogButtonView.swift +// Ferrite +// +// Created by Brian Dashore on 8/5/22. +// + +import SwiftUI + +struct PluginCatalogButtonView: 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) + } +} diff --git a/Ferrite/Views/ComponentViews/Source/Buttons/SourceCatalogButtonView.swift b/Ferrite/Views/ComponentViews/Plugin/Buttons/SourceCatalogButtonView.swift similarity index 86% rename from Ferrite/Views/ComponentViews/Source/Buttons/SourceCatalogButtonView.swift rename to Ferrite/Views/ComponentViews/Plugin/Buttons/SourceCatalogButtonView.swift index cdfa129..c8a7479 100644 --- a/Ferrite/Views/ComponentViews/Source/Buttons/SourceCatalogButtonView.swift +++ b/Ferrite/Views/ComponentViews/Plugin/Buttons/SourceCatalogButtonView.swift @@ -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) } } } diff --git a/Ferrite/Views/ComponentViews/Plugin/PluginListView.swift b/Ferrite/Views/ComponentViews/Plugin/PluginListView.swift new file mode 100644 index 0000000..9c5c5f6 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Plugin/PluginListView.swift @@ -0,0 +1,78 @@ +// +// SourceListView.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// +import SwiftUI + +struct PluginListView: 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 sourcePredicate: NSPredicate? + + var body: some View { + DynamicFetchRequest(predicate: sourcePredicate) { (installedPlugins: FetchedResults

) in + List { + if + let filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins( + forType: PJ.self, + installedPlugins: installedPlugins, + searchText: searchText + ), + !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 + let filteredAvailablePlugins = pluginManager.fetchFilteredPlugins( + forType: PJ.self, + installedPlugins: installedPlugins, + searchText: searchText + ), + !filteredAvailablePlugins.isEmpty + { + Section(header: InlineHeader("Catalog")) { + ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in + PluginCatalogButtonView(availablePlugin: availablePlugin, doUpsert: false) + } + } + } + } + .inlinedList(inset: 0) + .listStyle(.insetGrouped) + .sheet(isPresented: $navModel.showSourceSettings) { + if String(describing: P.self) == "Source" { + SourceSettingsView() + .environmentObject(navModel) + } + } + .onChange(of: searchText) { _ in + sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText) + } + } + } +} diff --git a/Ferrite/Views/ComponentViews/Plugin/PluginPickerView.swift b/Ferrite/Views/ComponentViews/Plugin/PluginPickerView.swift new file mode 100644 index 0000000..bb02771 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Plugin/PluginPickerView.swift @@ -0,0 +1,27 @@ +// +// PluginPickerView.swift +// Ferrite +// +// Created by Brian Dashore on 2/14/23. +// + +import SwiftUI + +struct PluginPickerView: View { + @Environment(\.verticalSizeClass) var verticalSizeClass + + @EnvironmentObject var navModel: NavigationViewModel + + @State private var textName = "" + @State private var secondTextName = "" + + var body: some View { + Picker("Segments", selection: $navModel.pluginPickerSelection) { + Text("Sources").tag(NavigationViewModel.PluginPickerSegment.sources) + Text("Actions").tag(NavigationViewModel.PluginPickerSegment.actions) + } + .pickerStyle(.segmented) + .padding(.horizontal, verticalSizeClass == .compact && UIDevice.current.hasNotch ? 65 : 18) + .padding(.vertical, 5) + } +} diff --git a/Ferrite/Views/ComponentViews/Plugin/PluginTagsView.swift b/Ferrite/Views/ComponentViews/Plugin/PluginTagsView.swift new file mode 100644 index 0000000..568d873 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Plugin/PluginTagsView.swift @@ -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) }) + } + } + } + } +} diff --git a/Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift b/Ferrite/Views/ComponentViews/Plugin/Source/SourceSettingsView.swift similarity index 80% rename from Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift rename to Ferrite/Views/ComponentViews/Plugin/Source/SourceSettingsView.swift index 2d70f74..fa30ab4 100644 --- a/Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift +++ b/Ferrite/Views/ComponentViews/Plugin/Source/SourceSettingsView.swift @@ -12,33 +12,46 @@ struct SourceSettingsView: View { @EnvironmentObject var navModel: NavigationViewModel + @FetchRequest( + entity: PluginList.entity(), + sortDescriptors: [] + ) var pluginLists: FetchedResults + var body: some View { NavView { 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 pluginList = pluginLists.first(where: { $0.id == selectedSource.listId }) + { + Text("List: \(pluginList.name)") + Text("List ID: \(pluginList.id.uuidString)") + } else { + Text("No plugin list 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) } @@ -91,7 +104,7 @@ struct SourceSettingsBaseUrlView: View { } }) .keyboardType(.URL) - .onAppear { + .backport.onAppear { tempBaseUrl = selectedSource.baseUrl ?? "" } } @@ -121,7 +134,7 @@ struct SourceSettingsApiView: View { } }) .autocapitalization(.none) - .onAppear { + .backport.onAppear { tempClientId = clientId.value ?? "" } } @@ -134,7 +147,7 @@ struct SourceSettingsApiView: View { } }) .autocapitalization(.none) - .onAppear { + .backport.onAppear { tempClientSecret = clientSecret.value ?? "" } } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift new file mode 100644 index 0000000..8834060 --- /dev/null +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift @@ -0,0 +1,49 @@ +// +// SearchFilterHeaderView.swift +// Ferrite +// +// Created by Brian Dashore on 2/13/23. +// + +import SwiftUI + +struct SearchFilterHeaderView: View { + @Environment(\.verticalSizeClass) var verticalSizeClass + + @EnvironmentObject var scrapingModel: ScrapingViewModel + @EnvironmentObject var debridManager: DebridManager + + @FetchRequest( + entity: Source.entity(), + sortDescriptors: [] + ) var sources: FetchedResults + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + Menu { + Picker("", selection: $scrapingModel.filteredSource) { + Text("None").tag(nil as Source?) + + ForEach(sources, id: \.self) { source in + if let name = source.name, source.enabled { + Text(name) + .tag(Source?.some(source)) + } + } + } + } label: { + FilterLabelView(name: scrapingModel.filteredSource?.name ?? "Source") + } + .id(scrapingModel.filteredSource) + + DebridPickerView() { + FilterLabelView(name: debridManager.selectedDebridType?.toString() ?? "Debrid") + } + .id(debridManager.selectedDebridType) + } + .padding(.horizontal, verticalSizeClass == .compact ? (Application.shared.osVersion.majorVersion > 14 ? 65 : 18) : 18) + .padding(.top, Application.shared.osVersion.majorVersion > 14 ? 0 : 10) + } + } +} diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index 40a1ffa..19409b0 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -12,6 +12,7 @@ struct SearchResultButtonView: View { @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var pluginManager: PluginManager var result: SearchResult @@ -38,12 +39,16 @@ struct SearchResultButtonView: View { name: result.title, url: debridManager.downloadUrl, source: result.source - ) + ), + performSave: true ) - navModel.runDebridAction(urlString: debridManager.downloadUrl) + pluginManager.runDebridAction( + urlString: debridManager.downloadUrl, + currentChoiceSheet: &navModel.currentChoiceSheet + ) - if navModel.currentChoiceSheet != .magnet { + if navModel.currentChoiceSheet != .action { debridManager.downloadUrl = "" } } @@ -63,10 +68,14 @@ struct SearchResultButtonView: View { name: result.title, url: result.magnet.link, source: result.source - ) + ), + performSave: true ) - navModel.runMagnetAction(magnet: result.magnet) + pluginManager.runMagnetAction( + urlString: result.magnet.link, + currentChoiceSheet: &navModel.currentChoiceSheet + ) } } } label: { @@ -136,7 +145,7 @@ struct SearchResultButtonView: View { existingBookmark = nil } } - .onAppear { + .backport.onAppear { // Only run a exists request if a bookmark isn't passed to the view if existingBookmark == nil, !runOnce { let bookmarkRequest = Bookmark.fetchRequest() diff --git a/Ferrite/Views/ComponentViews/Settings/BackupsView.swift b/Ferrite/Views/ComponentViews/Settings/BackupsView.swift index 6a91153..b6488b7 100644 --- a/Ferrite/Views/ComponentViews/Settings/BackupsView.swift +++ b/Ferrite/Views/ComponentViews/Settings/BackupsView.swift @@ -44,11 +44,11 @@ struct BackupsView: View { } } } - .inlinedList() + .inlinedList(inset: -20) .listStyle(.insetGrouped) } } - .onAppear { + .backport.onAppear { backupManager.backupUrls = FileManager.default.appDirectory .appendingPathComponent("Backups", isDirectory: true).contentsByDateAdded } @@ -57,7 +57,9 @@ struct BackupsView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - backupManager.createBackup() + Task { + await backupManager.createBackup() + } } label: { Image(systemName: "plus") } diff --git a/Ferrite/Views/ComponentViews/Settings/DefaultActionPickerView.swift b/Ferrite/Views/ComponentViews/Settings/DefaultActionPickerView.swift new file mode 100644 index 0000000..def50f6 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Settings/DefaultActionPickerView.swift @@ -0,0 +1,109 @@ +// +// DefaultActionsPickerViews.swift +// Ferrite +// +// Created by Brian Dashore on 8/11/22. +// + +import SwiftUI + +struct DefaultActionPickerView: View { + @EnvironmentObject var toastModel: ToastViewModel + + let actionRequirement: ActionRequirement + @Binding var defaultActionName: String? + @Binding var defaultActionList: String? + + @FetchRequest( + entity: Action.entity(), + sortDescriptors: [] + ) var actions: FetchedResults + + @FetchRequest( + entity: PluginList.entity(), + sortDescriptors: [] + ) var pluginLists: FetchedResults + + var body: some View { + List { + UserChoiceButton( + defaultActionName: $defaultActionName, + defaultActionList: $defaultActionList, + pluginLists: pluginLists + ) + + ForEach(actions.filter { $0.requires.contains(actionRequirement.rawValue) }, id: \.id) { action in + Button { + if let actionListId = action.listId?.uuidString { + defaultActionName = action.name + defaultActionList = actionListId + } else { + toastModel.updateToastDescription( + "Default action error: This action doesn't have a corresponding plugin list! Please uninstall the action" + ) + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(action.name) + + Group { + if let pluginList = pluginLists.first(where: { $0.id == action.listId }) { + Text("List: \(pluginList.name)") + + Text(pluginList.id.uuidString) + .font(.caption) + } else { + Text("No plugin list found") + .font(.caption) + } + } + .foregroundColor(.secondary) + } + Spacer() + + if + let defaultActionList, + action.listId?.uuidString == defaultActionList, + action.name == defaultActionName + { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + .backport.tint(.primary) + } + } + .listStyle(.insetGrouped) + .inlinedList(inset: -20) + .navigationTitle("Default \(actionRequirement == .debrid ? "debrid" : "magnet") action") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct UserChoiceButton: View { + @Binding var defaultActionName: String? + @Binding var defaultActionList: String? + var pluginLists: FetchedResults + + var body: some View { + Button { + defaultActionName = nil + defaultActionList = nil + } label: { + HStack { + Text("Let me choose") + Spacer() + + // Force "Let me choose" if the name OR list ID is nil + // Prevents any mismatches + if defaultActionName == nil || pluginLists.contains(where: { $0.id.uuidString == defaultActionList }) { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + .backport.tint(.primary) + } +} diff --git a/Ferrite/Views/ComponentViews/Settings/DefaultActionsPickerViews.swift b/Ferrite/Views/ComponentViews/Settings/DefaultActionsPickerViews.swift deleted file mode 100644 index c9c069c..0000000 --- a/Ferrite/Views/ComponentViews/Settings/DefaultActionsPickerViews.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// DefaultActionsPickerViews.swift -// Ferrite -// -// Created by Brian Dashore on 8/11/22. -// - -import SwiftUI - -struct MagnetActionPickerView: View { - @AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none - - var body: some View { - List { - ForEach(DefaultMagnetActionType.allCases, id: \.self) { action in - Button { - defaultMagnetAction = action - } label: { - HStack { - Text(fetchPickerChoiceName(choice: action)) - Spacer() - if action == defaultMagnetAction { - Image(systemName: "checkmark") - .foregroundColor(.blue) - } - } - } - .backport.tint(.primary) - } - } - .listStyle(.insetGrouped) - .inlinedList() - .navigationTitle("Default magnet action") - .navigationBarTitleDisplayMode(.inline) - } - - func fetchPickerChoiceName(choice: DefaultMagnetActionType) -> String { - switch choice { - case .none: - return "Let me choose" - case .webtor: - return "Open in Webtor" - case .shareMagnet: - return "Share magnet link" - } - } -} - -struct DebridActionPickerView: View { - @AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none - - var body: some View { - List { - ForEach(DefaultDebridActionType.allCases, id: \.self) { action in - Button { - defaultDebridAction = action - } label: { - HStack { - Text(fetchPickerChoiceName(choice: action)) - Spacer() - if action == defaultDebridAction { - Image(systemName: "checkmark") - .foregroundColor(.blue) - } - } - } - .backport.tint(.primary) - } - } - .listStyle(.insetGrouped) - .inlinedList() - .navigationTitle("Default debrid action") - .navigationBarTitleDisplayMode(.inline) - } - - func fetchPickerChoiceName(choice: DefaultDebridActionType) -> String { - switch choice { - case .none: - return "Let me choose" - case .outplayer: - return "Open in Outplayer" - case .vlc: - return "Open in VLC" - case .infuse: - return "Open in Infuse" - case .shareDownload: - return "Share download link" - } - } -} diff --git a/Ferrite/Views/ComponentViews/Settings/SourceListEditorView.swift b/Ferrite/Views/ComponentViews/Settings/PluginListEditorView.swift similarity index 57% rename from Ferrite/Views/ComponentViews/Settings/SourceListEditorView.swift rename to Ferrite/Views/ComponentViews/Settings/PluginListEditorView.swift index a7fd295..99c5bcf 100644 --- a/Ferrite/Views/ComponentViews/Settings/SourceListEditorView.swift +++ b/Ferrite/Views/ComponentViews/Settings/PluginListEditorView.swift @@ -7,38 +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 ?? "" + .backport.onAppear { + pluginListUrl = selectedPluginList?.urlString ?? "" sourceUrlSet = true } .backport.alert( - isPresented: $sourceManager.showUrlErrorAlert, + isPresented: $showUrlErrorAlert, title: "Error", - message: sourceManager.urlErrorAlertText, - buttons: [AlertButton("OK")] + message: urlErrorAlertText ) - .navigationTitle("Editing source list") + .navigationTitle("Editing Plugin List") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -50,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() } } diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift index d340ccb..69b05f8 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift @@ -30,7 +30,7 @@ struct SettingsAppVersionView: View { .listStyle(.insetGrouped) } } - .onAppear { + .backport.onAppear { viewTask = Task { do { if let fetchedReleases = try await Github().fetchReleases() { diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsSourceListView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsPluginListView.swift similarity index 69% rename from Ferrite/Views/ComponentViews/Settings/SettingsSourceListView.swift rename to Ferrite/Views/ComponentViews/Settings/SettingsPluginListView.swift index 7ab13d4..fe523ba 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsSourceListView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsPluginListView.swift @@ -7,40 +7,41 @@ 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 + ) var pluginLists: FetchedResults @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) - .foregroundColor(.gray) + Group { + Text(pluginList.author) - Text("ID: \(sourceList.id)") - .font(.caption) - .foregroundColor(.gray) + Text("ID: \(pluginList.id)") + .font(.caption) + } + .foregroundColor(.secondary) } .padding(.vertical, 2) .contextMenu { Button { - navModel.selectedSourceList = sourceList + selectedPluginList = pluginList presentSourceSheet.toggle() } label: { Text("Edit") @@ -49,14 +50,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,25 +67,25 @@ 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) } } } } .listStyle(.insetGrouped) - .inlinedList() + .inlinedList(inset: -20) } } .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 +99,8 @@ struct SettingsSourceListView: View { } } -struct SettingsSourceListView_Previews: PreviewProvider { +struct SettingsPluginListView_Previews: PreviewProvider { static var previews: some View { - SettingsSourceListView() + SettingsPluginListView() } } diff --git a/Ferrite/Views/ComponentViews/Source/Buttons/SourceUpdateButtonView.swift b/Ferrite/Views/ComponentViews/Source/Buttons/SourceUpdateButtonView.swift deleted file mode 100644 index 2b0f1f2..0000000 --- a/Ferrite/Views/ComponentViews/Source/Buttons/SourceUpdateButtonView.swift +++ /dev/null @@ -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) - } - } - } - } -} diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 28471f6..36db8af 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -12,120 +12,129 @@ struct ContentView: View { @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var navModel: NavigationViewModel - @EnvironmentObject var sourceManager: SourceManager + @EnvironmentObject var pluginManager: PluginManager + @EnvironmentObject var toastModel: ToastViewModel - @FetchRequest( - entity: Source.entity(), - sortDescriptors: [] - ) var sources: FetchedResults + @AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText: Bool = false - @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true + @State private var isEditingSearch = false + @State private var isSearching = false + @State private var searchText: String = "" - @State private var selectedSource: Source? { - didSet { - scrapingModel.filteredSource = selectedSource - } - } + @State private var lastSearchTextIndex: Int = -1 + @State private var searchBarText: String = "Search" + let searchBarTextArray: [String] = [ + "What's on your mind?", + "Discover something interesting", + "Find an engaging show", + "Feeling adventurous?", + "Look for something new", + "The classics are a good idea" + ] var body: some View { NavView { - VStack(spacing: 10) { - HStack(spacing: 6) { - Text("Filter") - .foregroundColor(.secondary) - - Menu { - Button { - selectedSource = nil - } label: { - Text("None") - - if selectedSource == nil { - Image(systemName: "checkmark") - } - } - - ForEach(sources, id: \.self) { source in - if let name = source.name, source.enabled { - Button { - selectedSource = source - } label: { - if selectedSource == source { - Label(name, systemImage: "checkmark") - } else { - Text(name) - } - } - } - } - } label: { - Text(selectedSource?.name ?? "Source") - .padding(.trailing, -3) - Image(systemName: "chevron.down") + List { + ForEach(scrapingModel.searchResults, id: \.self) { result in + if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil { + SearchResultButtonView(result: result) } - .foregroundColor(.primary) - .animation(.none) - - Spacer() } - .padding(.vertical, 5) - .padding(.horizontal, 20) - - SearchResultsView() + } + .listStyle(.insetGrouped) + .inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 20 : -20) + .overlay { + if scrapingModel.searchResults.isEmpty && isSearching && scrapingModel.runningSearchTask == nil { + Text("No results found") + } + } + .onChange(of: searchText) { newText in + if newText.isEmpty && isSearching { + searchBarText = getSearchBarText() + } + } + .onChange(of: scrapingModel.searchResults) { _ in + // Cleans up any leftover search results in the event of an abrupt cancellation + if !isSearching { + scrapingModel.searchResults = [] + } + } + .onChange(of: navModel.selectedTab) { tab in + // Cancel the search if tab is switched while search is in progress + if tab != .search, scrapingModel.runningSearchTask != nil { + scrapingModel.searchResults = [] + scrapingModel.runningSearchTask?.cancel() + scrapingModel.runningSearchTask = nil + isSearching = false + searchText = "" + } } .navigationTitle("Search") - .navigationBarTitleDisplayMode( - navModel.isSearching && Application.shared.osVersion.majorVersion > 14 ? .inline : .large - ) .navigationSearchBar { - SearchBar("Search", - text: $scrapingModel.searchText, - isEditing: $navModel.isEditingSearch, - onCommit: { - scrapingModel.searchResults = [] - scrapingModel.runningSearchTask = Task { - navModel.isSearching = true - navModel.showSearchProgress = true + SearchBar( + searchBarText, + text: $searchText, + isEditing: $isEditingSearch, + onCommit: { + if let runningSearchTask = scrapingModel.runningSearchTask, runningSearchTask.isCancelled { + scrapingModel.runningSearchTask = nil + return + } - let sources = sourceManager.fetchInstalledSources() - await scrapingModel.scanSources(sources: sources) + scrapingModel.runningSearchTask = Task { + isSearching = true - if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty { - debridManager.clearIAValues() + let sources = pluginManager.fetchInstalledSources() + await scrapingModel.scanSources(sources: sources, searchText: searchText) - // Remove magnets that don't have a hash - let magnets = scrapingModel.searchResults.compactMap { - if let magnetHash = $0.magnet.hash { - return Magnet(hash: magnetHash, link: $0.magnet.link) - } else { - return nil - } - } - await debridManager.populateDebridIA(magnets) - } + if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty { + debridManager.clearIAValues() - navModel.showSearchProgress = false - } - }) - .showsCancelButton(navModel.isEditingSearch || navModel.isSearching) - .onCancel { - scrapingModel.searchResults = [] - scrapingModel.runningSearchTask?.cancel() - scrapingModel.runningSearchTask = nil - navModel.isSearching = false - scrapingModel.searchText = "" - } - } - .introspectSearchController { searchController in - searchController.hidesNavigationBarDuringPresentation = false - searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no - searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - DebridChoiceView() + let magnets = scrapingModel.searchResults.map(\.magnet) + await debridManager.populateDebridIA(magnets) + } + + toastModel.hideIndeterminateToast() + scrapingModel.runningSearchTask = nil + } + } + ) + .showsCancelButton(isEditingSearch || isSearching) + .onCancel { + scrapingModel.searchResults = [] + scrapingModel.runningSearchTask?.cancel() + scrapingModel.runningSearchTask = nil + isSearching = false + searchText = "" + searchBarText = getSearchBarText() } } + .navigationSearchBarHiddenWhenScrolling(false) + .customScopeBar { + SearchFilterHeaderView() + .environmentObject(scrapingModel) + .environmentObject(debridManager) + } + } + .backport.onAppear { + searchBarText = getSearchBarText() + } + } + + // Fetches random searchbar text if enabled, otherwise deinit the last case value + func getSearchBarText() -> String { + if usesRandomSearchText { + let num = Int.random(in: 0.. + + @FetchRequest( + entity: Action.entity(), + sortDescriptors: [] + ) var actions: FetchedResults + + @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true + + @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? + + var body: some View { + NavView { + ZStack { + if checkedForPlugins { + switch navModel.pluginPickerSelection { + case .sources: + PluginListView(searchText: $searchText) + case .actions: + PluginListView(searchText: $searchText) + } + } + } + .overlay { + if checkedForPlugins { + switch navModel.pluginPickerSelection { + 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() + } + } + .backport.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 + } + } + .navigationSearchBarHiddenWhenScrolling(false) + .customScopeBar { + PluginPickerView() + .environmentObject(navModel) + } + } + } +} + +struct PluginsView_Previews: PreviewProvider { + static var previews: some View { + PluginsView() + } +} diff --git a/Ferrite/Views/RepresentableViews/ViewDidAppearHandler.swift b/Ferrite/Views/RepresentableViews/ViewDidAppearHandler.swift new file mode 100644 index 0000000..c643486 --- /dev/null +++ b/Ferrite/Views/RepresentableViews/ViewDidAppearHandler.swift @@ -0,0 +1,43 @@ +// +// ViewDidAppearHandler.swift +// Ferrite +// +// Created by Brian Dashore on 2/8/23. +// +// UIKit onAppear hook to fix onAppear behavior in iOS 14 +// + +import SwiftUI + +struct ViewDidAppearHandler: UIViewControllerRepresentable { + let callback: () -> Void + + class Coordinator: UIViewController { + let callback: () -> Void + + init(callback: @escaping () -> Void) { + self.callback = callback + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + callback() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(callback: callback) + } + + func makeUIViewController(context: Context) -> UIViewController { + context.coordinator + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} diff --git a/Ferrite/Views/SearchResultsView.swift b/Ferrite/Views/SearchResultsView.swift deleted file mode 100644 index df775a7..0000000 --- a/Ferrite/Views/SearchResultsView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// SearchResultsView.swift -// Ferrite -// -// Created by Brian Dashore on 7/11/22. -// - -import SwiftUI - -struct SearchResultsView: View { - @EnvironmentObject var scrapingModel: ScrapingViewModel - @EnvironmentObject var navModel: NavigationViewModel - @EnvironmentObject var debridManager: DebridManager - - var body: some View { - List { - ForEach(scrapingModel.searchResults, id: \.self) { result in - if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil { - SearchResultButtonView(result: result) - } - } - } - .listStyle(.insetGrouped) - .inlinedList() - .overlay { - if scrapingModel.searchResults.isEmpty { - if navModel.showSearchProgress { - VStack(spacing: 5) { - ProgressView() - Text("Loading \(scrapingModel.currentSourceName ?? "")") - } - } else if navModel.isSearching, scrapingModel.runningSearchTask != nil { - Text("No results found") - } - } - } - .onChange(of: navModel.selectedTab) { tab in - // Cancel the search if tab is switched while search is in progress - if tab != .search, navModel.showSearchProgress { - scrapingModel.searchResults = [] - scrapingModel.runningSearchTask?.cancel() - scrapingModel.runningSearchTask = nil - navModel.isSearching = false - scrapingModel.searchText = "" - } - } - .onChange(of: scrapingModel.searchResults) { _ in - // Cleans up any leftover search results in the event of an abrupt cancellation - if !navModel.isSearching { - scrapingModel.searchResults = [] - } - } - } -} - -struct SearchResultsView_Previews: PreviewProvider { - static var previews: some View { - SearchResultsView() - } -} diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index f4656c0..1c38209 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -11,16 +11,20 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var debridManager: DebridManager - @EnvironmentObject var sourceManager: SourceManager + @EnvironmentObject var pluginManager: PluginManager let backgroundContext = PersistenceController.shared.backgroundContext @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true + @AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false @AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true - @AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none - @AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none + @AppStorage("Actions.DefaultDebridName") var defaultDebridActionName: String? + @AppStorage("Actions.DefaultDebridList") var defaultDebridActionList: String? + + @AppStorage("Actions.DefaultMagnetName") var defaultMagnetActionName: String? + @AppStorage("Actions.DefaultMagnetList") var defaultMagnetActionList: String? var body: some View { NavView { @@ -78,80 +82,74 @@ struct SettingsView: View { } } - Section(header: Text("Behavior")) { + Section(header: InlineHeader("Behavior")) { Toggle(isOn: $autocorrectSearch) { Text("Autocorrect search") } + + Toggle(isOn: $usesRandomSearchText) { + Text("Random searchbar text") + } } - Section(header: Text("Source management")) { - NavigationLink("Source lists", destination: SettingsSourceListView()) + Section(header: InlineHeader("Plugin management")) { + NavigationLink("Plugin lists", destination: SettingsPluginListView()) } - Section(header: Text("Default actions")) { - if debridManager.enabledDebrids.count > 0 { + Section(header: InlineHeader("Default actions")) { + //if debridManager.enabledDebrids.count > 0 { NavigationLink( - destination: DebridActionPickerView(), + destination: DefaultActionPickerView( + actionRequirement: .debrid, + defaultActionName: $defaultDebridActionName, + defaultActionList: $defaultDebridActionList + ), label: { HStack { - Text("Default debrid action") + Text("Debrid action") Spacer() - Group { - switch defaultDebridAction { - case .none: - Text("User choice") - case .outplayer: - Text("Outplayer") - case .vlc: - Text("VLC") - case .infuse: - Text("Infuse") - case .shareDownload: - Text("Share") - } - } - .foregroundColor(.gray) + + // TODO: Maybe make this check for nil list as well? + Text(defaultDebridActionName.map { $0 } ?? "User choice") + .foregroundColor(.secondary) } } ) - } + //} NavigationLink( - destination: MagnetActionPickerView(), + destination: DefaultActionPickerView( + actionRequirement: .magnet, + defaultActionName: $defaultMagnetActionName, + defaultActionList: $defaultMagnetActionList + ), label: { HStack { - Text("Default magnet action") + Text("Magnet action") Spacer() - Group { - switch defaultMagnetAction { - case .none: - Text("User choice") - case .webtor: - Text("Webtor") - case .shareMagnet: - Text("Share") - } - } - .foregroundColor(.gray) + + // TODO: Maybe make this check for nil list as well? + Text(defaultMagnetActionName.map { $0 } ?? "User choice") + .foregroundColor(.secondary) } } ) } - Section(header: Text("Backups")) { + Section(header: InlineHeader("Backups")) { NavigationLink(destination: BackupsView()) { Text("Backups") } } - Section(header: Text("Updates")) { + Section(header: InlineHeader("Updates")) { Toggle(isOn: $autoUpdateNotifs) { Text("Show update alerts") } NavigationLink("Version history", destination: SettingsAppVersionView()) } - Section(header: Text("Information")) { + Section(header: InlineHeader("Information")) { ListRowLinkView(text: "Donate", link: "https://ko-fi.com/kingbri") ListRowLinkView(text: "Report issues", link: "https://github.com/bdashore3/Ferrite/issues") NavigationLink("About", destination: AboutView()) diff --git a/Ferrite/Views/SheetViews/MagnetChoiceView.swift b/Ferrite/Views/SheetViews/ActionChoiceView.swift similarity index 76% rename from Ferrite/Views/SheetViews/MagnetChoiceView.swift rename to Ferrite/Views/SheetViews/ActionChoiceView.swift index 1690ddb..d9b4839 100644 --- a/Ferrite/Views/SheetViews/MagnetChoiceView.swift +++ b/Ferrite/Views/SheetViews/ActionChoiceView.swift @@ -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 @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) - } } } } @@ -108,6 +112,13 @@ struct MagnetChoiceView: View { AppActivityView(activityItems: navModel.activityItems) } } + .backport.alert( + isPresented: $pluginManager.showBrokenDefaultActionAlert, + title: "Action not found", + message: + "The default action could not be run. The action choice sheet has been opened. \n\n" + + "Please check your default actions in Settings" + ) .onDisappear { debridManager.downloadUrl = "" navModel.selectedTitle = "" @@ -131,8 +142,8 @@ struct MagnetChoiceView: View { } } -struct MagnetChoiceView_Previews: PreviewProvider { +struct ActionChoiceView_Previews: PreviewProvider { static var previews: some View { - MagnetChoiceView() + ActionChoiceView() } } diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift index 00edb19..1721adb 100644 --- a/Ferrite/Views/SheetViews/BatchChoiceView.swift +++ b/Ferrite/Views/SheetViews/BatchChoiceView.swift @@ -11,9 +11,11 @@ struct BatchChoiceView: View { @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var pluginManager: PluginManager let backgroundContext = PersistenceController.shared.backgroundContext + // TODO: Make this generic for IA(?) and add searchbar var body: some View { NavView { List { @@ -48,7 +50,7 @@ struct BatchChoiceView: View { } .backport.tint(.primary) .listStyle(.insetGrouped) - .inlinedList() + .inlinedList(inset: -20) .navigationTitle("Select a file") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -79,10 +81,13 @@ 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) + pluginManager.runDebridAction( + urlString: debridManager.downloadUrl, + currentChoiceSheet: &navModel.currentChoiceSheet + ) } debridManager.clearSelectedDebridItems() diff --git a/Ferrite/Views/SourcesView.swift b/Ferrite/Views/SourcesView.swift deleted file mode 100644 index eb9c708..0000000 --- a/Ferrite/Views/SourcesView.swift +++ /dev/null @@ -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? = 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) 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() - } -}