diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index b7c2294..82d4ffe 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -21,6 +21,9 @@ 0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; }; 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 */; }; + 0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */; }; + 0C794B69289DACC800DD1CC8 /* InstalledSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */; }; + 0C794B6B289DACF100DD1CC8 /* SourceCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */; }; 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 */; }; 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F4752895BE680074B7C9 /* FerriteDB.xcdatamodeld */; }; @@ -74,6 +77,9 @@ 0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = ""; }; 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = ""; }; 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataProperties.swift"; sourceTree = ""; }; + 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceUpdateButtonView.swift; sourceTree = ""; }; + 0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledSourceView.swift; sourceTree = ""; }; + 0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceCatalogView.swift; sourceTree = ""; }; 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 = ""; }; 0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB.xcdatamodel; sourceTree = ""; }; @@ -160,6 +166,17 @@ path = Models; sourceTree = ""; }; + 0C794B65289DAC9F00DD1CC8 /* SourceViews */ = { + isa = PBXGroup; + children = ( + 0C733286289C4C820058D1FE /* SourceSettingsView.swift */, + 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */, + 0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */, + 0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */, + ); + path = SourceViews; + sourceTree = ""; + }; 0CA0545C288F7CB200850554 /* SettingsViews */ = { isa = PBXGroup; children = ( @@ -217,6 +234,7 @@ 0CA148EE2889061200DE2211 /* Views */ = { isa = PBXGroup; children = ( + 0C794B65289DAC9F00DD1CC8 /* SourceViews */, 0CA148F02889062700DE2211 /* RepresentableViews */, 0CA148C0288903F000DE2211 /* CommonViews */, 0CA0545C288F7CB200850554 /* SettingsViews */, @@ -229,7 +247,6 @@ 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */, - 0C733286289C4C820058D1FE /* SourceSettingsView.swift */, 0C32FB522890D19D002BD219 /* AboutView.swift */, ); path = Views; @@ -381,6 +398,7 @@ 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */, 0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */, 0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */, + 0C794B6B289DACF100DD1CC8 /* SourceCatalogView.swift in Sources */, 0CA148E9288903F000DE2211 /* MainView.swift in Sources */, 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, 0CF501F2289AE06A0099C785 /* SourceTracker+CoreDataClass.swift in Sources */, @@ -389,6 +407,7 @@ 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0CA148E1288903F000DE2211 /* Collection.swift in Sources */, 0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */, + 0C794B69289DACC800DD1CC8 /* InstalledSourceView.swift in Sources */, 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, 0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */, @@ -397,6 +416,7 @@ 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */, 0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */, 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */, + 0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */, 0CA148E6288903F000DE2211 /* WebView.swift in Sources */, 0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */, 0CA148E2288903F000DE2211 /* Data.swift in Sources */, diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index f5a38d5..5b4e2f2 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -23,4 +23,7 @@ class NavigationViewModel: ObservableObject { // Used between SourceListView and SourceSettingsView @Published var showSourceSettings: Bool = false @Published var selectedSource: Source? + + @Published var showSourceListEditor: Bool = false + @Published var selectedSourceList: SourceList? } diff --git a/Ferrite/ViewModels/SourceManager.swift b/Ferrite/ViewModels/SourceManager.swift index 724132b..5c7de64 100644 --- a/Ferrite/ViewModels/SourceManager.swift +++ b/Ferrite/ViewModels/SourceManager.swift @@ -45,21 +45,23 @@ public class SourceManager: ObservableObject { } } - public func installSource(sourceJson: SourceJson) { + public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) { let backgroundContext = PersistenceController.shared.backgroundContext - // If a source exists, don't add the new one + // 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 - let existingSource = try? backgroundContext.fetch(existingSourceRequest).first - if existingSource != nil { - Task { @MainActor in - toastModel?.toastDescription = "Could not install source with name \(sourceJson.name) because it is already installed." + if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first { + if doUpsert { + PersistenceController.shared.delete(existingSource, context: backgroundContext) + } else { + Task { @MainActor in + toastModel?.toastDescription = "Could not install source with name \(sourceJson.name) because it is already installed." + } + return } - - return } let newSource = Source(context: backgroundContext) @@ -218,7 +220,7 @@ public class SourceManager: ObservableObject { } @MainActor - public func addSourceList(sourceUrl: String) async -> Bool { + public func addSourceList(sourceUrl: String, existingSourceList: SourceList?) async -> Bool { let backgroundContext = PersistenceController.shared.backgroundContext if sourceUrl.isEmpty || URL(string: sourceUrl) == nil { @@ -232,22 +234,31 @@ public class SourceManager: ObservableObject { let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!)) let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data) - let sourceListRequest = SourceList.fetchRequest() - sourceListRequest.predicate = NSPredicate(format: "urlString == %@ OR author == %@", sourceUrl, rawResponse.author) - sourceListRequest.fetchLimit = 1 + if let existingSourceList = existingSourceList { + existingSourceList.urlString = sourceUrl + existingSourceList.name = rawResponse.name + existingSourceList.author = rawResponse.author + } 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.init(type: .or, subpredicates: [urlPredicate, infoPredicate]) + sourceListRequest.fetchLimit = 1 - if (try? backgroundContext.fetch(sourceListRequest).first) != nil { - urlErrorAlertText = "A source with the same URL or author exists. Please remove it and try again." - showUrlErrorAlert.toggle() - return false + 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 } - let newSourceUrl = SourceList(context: backgroundContext) - newSourceUrl.id = UUID() - newSourceUrl.urlString = sourceUrl - newSourceUrl.name = rawResponse.name - newSourceUrl.author = rawResponse.author - try backgroundContext.save() return true diff --git a/Ferrite/ViewModels/ToastViewModel.swift b/Ferrite/ViewModels/ToastViewModel.swift index a4cde3f..f88c022 100644 --- a/Ferrite/ViewModels/ToastViewModel.swift +++ b/Ferrite/ViewModels/ToastViewModel.swift @@ -1,5 +1,5 @@ // -// ErrorViewModel.swift +// ToastViewModel.swift // Ferrite // // Created by Brian Dashore on 7/19/22. diff --git a/Ferrite/Views/SettingsViews/SettingsSourceListView.swift b/Ferrite/Views/SettingsViews/SettingsSourceListView.swift index 9eb89fb..8781716 100644 --- a/Ferrite/Views/SettingsViews/SettingsSourceListView.swift +++ b/Ferrite/Views/SettingsViews/SettingsSourceListView.swift @@ -1,5 +1,5 @@ // -// SettingsSourceUrlView.swift +// SettingsSourceListView.swift // Ferrite // // Created by Brian Dashore on 7/25/22. @@ -10,27 +10,43 @@ import SwiftUI struct SettingsSourceListView: View { let backgroundContext = PersistenceController.shared.backgroundContext + @EnvironmentObject var navModel: NavigationViewModel + @FetchRequest( entity: SourceList.entity(), sortDescriptors: [] ) var sourceLists: FetchedResults @State private var presentSourceSheet = false + @State private var selectedSourceList: SourceList? var body: some View { List { ForEach(sourceLists, id: \.self) { sourceList in VStack(alignment: .leading, spacing: 5) { Text(sourceList.name) + + Text(sourceList.author) + .foregroundColor(.gray) + Text("ID: \(sourceList.id)") .font(.caption) .foregroundColor(.gray) } - } - .onDelete { offsets in - for index in offsets { - if let sourceUrl = sourceLists[safe: index] { - PersistenceController.shared.delete(sourceUrl, context: backgroundContext) + .contextMenu { + Button { + navModel.selectedSourceList = sourceList + presentSourceSheet.toggle() + } label: { + Text("Edit") + Image(systemName: "pencil") + } + + Button { + PersistenceController.shared.delete(sourceList, context: backgroundContext) + } label: { + Text("Remove") + Image(systemName: "trash") } } } diff --git a/Ferrite/Views/SettingsViews/SourceListEditorView.swift b/Ferrite/Views/SettingsViews/SourceListEditorView.swift index c7e085e..004e3fe 100644 --- a/Ferrite/Views/SettingsViews/SourceListEditorView.swift +++ b/Ferrite/Views/SettingsViews/SourceListEditorView.swift @@ -10,6 +10,7 @@ import SwiftUI struct SourceListEditorView: View { @Environment(\.dismiss) var dismiss + @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var sourceManager: SourceManager let backgroundContext = PersistenceController.shared.backgroundContext @@ -26,6 +27,9 @@ struct SourceListEditorView: View { .autocapitalization(.none) } } + .onAppear { + sourceUrl = navModel.selectedSourceList?.urlString ?? "" + } .alert(isPresented: $sourceManager.showUrlErrorAlert) { Alert( title: Text("Error"), @@ -45,13 +49,19 @@ struct SourceListEditorView: View { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { Task { - if await sourceManager.addSourceList(sourceUrl: sourceUrl) { + if await sourceManager.addSourceList( + sourceUrl: sourceUrl, + existingSourceList: navModel.selectedSourceList + ) { dismiss() } } } } } + .onDisappear { + navModel.selectedSourceList = nil + } } } } diff --git a/Ferrite/Views/SourceViews/InstalledSourceView.swift b/Ferrite/Views/SourceViews/InstalledSourceView.swift new file mode 100644 index 0000000..429ab9e --- /dev/null +++ b/Ferrite/Views/SourceViews/InstalledSourceView.swift @@ -0,0 +1,53 @@ +// +// InstalledSourceView.swift +// Ferrite +// +// Created by Brian Dashore on 8/5/22. +// + +import SwiftUI + +struct InstalledSourceView: View { + let backgroundContext = PersistenceController.shared.backgroundContext + + @EnvironmentObject var navModel: NavigationViewModel + + @ObservedObject var installedSource: Source + + var body: some View { + Toggle(isOn: Binding( + get: { installedSource.enabled }, + set: { + installedSource.enabled = $0 + PersistenceController.shared.save() + } + )) { + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(installedSource.name) + Text("v\(installedSource.version)") + .foregroundColor(.secondary) + } + + Text("by \(installedSource.author)") + .foregroundColor(.secondary) + } + } + .contextMenu { + Button { + navModel.selectedSource = installedSource + navModel.showSourceSettings.toggle() + } label: { + Text("Settings") + Image(systemName: "gear") + } + + Button { + PersistenceController.shared.delete(installedSource, context: backgroundContext) + } label: { + Text("Remove") + Image(systemName: "trash") + } + } + } +} diff --git a/Ferrite/Views/SourceViews/SourceCatalogView.swift b/Ferrite/Views/SourceViews/SourceCatalogView.swift new file mode 100644 index 0000000..1ebbfe5 --- /dev/null +++ b/Ferrite/Views/SourceViews/SourceCatalogView.swift @@ -0,0 +1,35 @@ +// +// SourceCatalogButtonView.swift +// Ferrite +// +// Created by Brian Dashore on 8/5/22. +// + +import SwiftUI + +struct SourceCatalogButtonView: View { + @EnvironmentObject var sourceManager: SourceManager + + let availableSource: SourceJson + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(availableSource.name) + Text("v\(availableSource.version)") + .foregroundColor(.secondary) + } + + Text("by \(availableSource.author ?? "Unknown")") + .foregroundColor(.secondary) + } + + Spacer() + + Button("Install") { + sourceManager.installSource(sourceJson: availableSource) + } + } + } +} diff --git a/Ferrite/Views/SourceSettingsView.swift b/Ferrite/Views/SourceViews/SourceSettingsView.swift similarity index 81% rename from Ferrite/Views/SourceSettingsView.swift rename to Ferrite/Views/SourceViews/SourceSettingsView.swift index b54576e..78a1f7b 100644 --- a/Ferrite/Views/SourceSettingsView.swift +++ b/Ferrite/Views/SourceViews/SourceSettingsView.swift @@ -22,20 +22,23 @@ struct SourceSettingsView: View { Text(selectedSource.name) Text("v\(selectedSource.version)") + .foregroundColor(.secondary) } Text("by \(selectedSource.author)") .foregroundColor(.secondary) - if let listId = selectedSource.listId { - Text("List ID: \(listId)") - .font(.caption) - .foregroundColor(.secondary) - } else { - Text("No list ID found. This source should be removed.") - .font(.caption) - .foregroundColor(.secondary) + Group { + Text("ID: \(selectedSource.id)") + + if let listId = selectedSource.listId { + Text("List ID: \(listId)") + } else { + Text("No list ID found. This source should be removed.") + } } + .foregroundColor(.secondary) + .font(.caption) } } diff --git a/Ferrite/Views/SourceViews/SourceUpdateButtonView.swift b/Ferrite/Views/SourceViews/SourceUpdateButtonView.swift new file mode 100644 index 0000000..9db6c0d --- /dev/null +++ b/Ferrite/Views/SourceViews/SourceUpdateButtonView.swift @@ -0,0 +1,35 @@ +// +// 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) + } + + Spacer() + + Button("Update") { + sourceManager.installSource(sourceJson: updatedSource, doUpsert: true) + } + } + } +} diff --git a/Ferrite/Views/SourcesView.swift b/Ferrite/Views/SourcesView.swift index 7c08608..2f6218d 100644 --- a/Ferrite/Views/SourcesView.swift +++ b/Ferrite/Views/SourcesView.swift @@ -18,48 +18,39 @@ struct SourcesView: View { sortDescriptors: [] ) var sources: FetchedResults - @State private var availableSourceLength = 0 + private var updatedSources: [SourceJson] { + var tempSources: [SourceJson] = [] + + for source in sources { + guard let availableSource = sourceManager.availableSources.first(where: { + source.listId == $0.listId && source.name == $0.name && source.author == $0.author + }) else { + continue + } + + if availableSource.version > source.version { + tempSources.append(availableSource) + } + } + + return tempSources + } var body: some View { NavView { List { + if !updatedSources.isEmpty { + Section("Updates") { + ForEach(updatedSources, id: \.self) { source in + SourceUpdateButtonView(updatedSource: source) + } + } + } + if !sources.isEmpty { Section("Installed") { ForEach(sources, id: \.self) { source in - Toggle(isOn: Binding( - get: { source.enabled }, - set: { - source.enabled = $0 - PersistenceController.shared.save() - } - )) { - VStack(alignment: .leading, spacing: 5) { - HStack { - Text(source.name) - Text("v\(source.version)") - .foregroundColor(.secondary) - } - - Text("by \(source.author)") - .foregroundColor(.secondary) - } - } - .contextMenu { - Button { - navModel.selectedSource = source - navModel.showSourceSettings.toggle() - } label: { - Text("Settings") - Image(systemName: "gear") - } - - Button { - PersistenceController.shared.delete(source, context: backgroundContext) - } label: { - Text("Remove") - Image(systemName: "trash") - } - } + InstalledSourceView(installedSource: source) } .sheet(isPresented: $navModel.showSourceSettings) { SourceSettingsView() @@ -67,30 +58,25 @@ struct SourcesView: View { } } - if sourceManager.availableSources.contains(where: { avail in - !sources.contains(where: { avail.name == $0.name }) + if sourceManager.availableSources.contains(where: { availableSource in + !sources.contains( + where: { + availableSource.name == $0.name && + availableSource.listId == $0.listId && + availableSource.author == $0.author + } + ) }) { Section("Catalog") { ForEach(sourceManager.availableSources, id: \.self) { availableSource in - if !sources.contains(where: { availableSource.name == $0.name }) { - HStack { - VStack(alignment: .leading, spacing: 5) { - HStack { - Text(availableSource.name) - Text("v\(availableSource.version)") - .foregroundColor(.secondary) - } - - Text("by \(availableSource.author ?? "Unknown")") - .foregroundColor(.secondary) - } - - Spacer() - - Button("Install") { - sourceManager.installSource(sourceJson: availableSource) - } + if !sources.contains( + where: { + availableSource.name == $0.name && + availableSource.listId == $0.listId && + availableSource.author == $0.author } + ) { + SourceCatalogButtonView(availableSource: availableSource) } } }