diff --git a/Localization/Localizations/Localizable.xcstrings b/Localization/Localizations/Localizable.xcstrings index 090555f..5826716 100644 --- a/Localization/Localizations/Localizable.xcstrings +++ b/Localization/Localizations/Localizable.xcstrings @@ -202,6 +202,12 @@ } } } + }, + "Add Feed" : { + + }, + "Add Folder" : { + }, "Add Server" : { "localizations" : { @@ -1054,6 +1060,7 @@ } }, "Feeds" : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { diff --git a/qBitControl.xcodeproj/project.pbxproj b/qBitControl.xcodeproj/project.pbxproj index 270f9b3..75bf29e 100644 --- a/qBitControl.xcodeproj/project.pbxproj +++ b/qBitControl.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ 9082FA2729080E55006C3960 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9082FA2629080E55006C3960 /* Assets.xcassets */; }; 9082FA2A29080E55006C3960 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9082FA2929080E55006C3960 /* Preview Assets.xcassets */; }; 9082FA4D29082208006C3960 /* qBittorrentClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9082FA4C29082208006C3960 /* qBittorrentClass.swift */; }; + 909EE5A22CDD3B51002403DF /* RSSNodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 909EE5A12CDD3B4E002403DF /* RSSNodeViewModel.swift */; }; 90AE89322CDB6F00000E0276 /* RSSViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90AE89312CDB6EFC000E0276 /* RSSViewModel.swift */; }; 90AE89342CDB731A000E0276 /* RSSNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90AE89332CDB7316000E0276 /* RSSNode.swift */; }; 90AE89392CDBEC4D000E0276 /* RSSNodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90AE89382CDBEC47000E0276 /* RSSNodeView.swift */; }; @@ -160,6 +161,7 @@ 9082FA2F29080E55006C3960 /* qBitControlTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = qBitControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9082FA3929080E55006C3960 /* qBitControlUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = qBitControlUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9082FA4C29082208006C3960 /* qBittorrentClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = qBittorrentClass.swift; sourceTree = ""; }; + 909EE5A12CDD3B4E002403DF /* RSSNodeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSNodeViewModel.swift; sourceTree = ""; }; 90AE89312CDB6EFC000E0276 /* RSSViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSViewModel.swift; sourceTree = ""; }; 90AE89332CDB7316000E0276 /* RSSNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSNode.swift; sourceTree = ""; }; 90AE89382CDBEC47000E0276 /* RSSNodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSNodeView.swift; sourceTree = ""; }; @@ -415,6 +417,7 @@ 90AE89302CDB6EF5000E0276 /* RSSView */ = { isa = PBXGroup; children = ( + 909EE5A12CDD3B4E002403DF /* RSSNodeViewModel.swift */, 90AE89312CDB6EFC000E0276 /* RSSViewModel.swift */, ); path = RSSView; @@ -609,6 +612,7 @@ 90726B242CDA22160032993E /* MainData.swift in Sources */, 90726B252CDA22160032993E /* PartialServerState.swift in Sources */, 90AE89322CDB6F00000E0276 /* RSSViewModel.swift in Sources */, + 909EE5A22CDD3B51002403DF /* RSSNodeViewModel.swift in Sources */, 90726B262CDA22160032993E /* Peer.swift in Sources */, 90726B272CDA22160032993E /* Peers.swift in Sources */, 90726B282CDA22160032993E /* qBitPreferences.swift in Sources */, diff --git a/qBitControl/Classes/qBittorrentClass.swift b/qBitControl/Classes/qBittorrentClass.swift index cc15370..69014fe 100644 --- a/qBitControl/Classes/qBittorrentClass.swift +++ b/qBitControl/Classes/qBittorrentClass.swift @@ -424,6 +424,18 @@ class qBittorrent { qBitRequest.requestRSSFeedJSON(request: request, completion: { RSSNodes in completionHandler(RSSNodes) }) } + static func addRSSFeed(url: String, path: String) { + let request = qBitRequest.prepareURLRequest(path: "/api/v2/rss/addFeed", queryItems: [URLQueryItem(name: "url", value: url), URLQueryItem(name: "path", value: path)]) + + qBitRequest.requestUniversal(request: request) + } + + static func addRSSFolder(path: String) { + let request = qBitRequest.prepareURLRequest(path: "/api/v2/rss/addFolder", queryItems: [URLQueryItem(name: "path", value: path)]) + + qBitRequest.requestUniversal(request: request) + } + static func removeTracker(hash: String, url: String) { let path = "/api/v2/torrents/removeTrackers" diff --git a/qBitControl/Models/Category.swift b/qBitControl/Models/Category.swift index 8008644..efac441 100644 --- a/qBitControl/Models/Category.swift +++ b/qBitControl/Models/Category.swift @@ -6,4 +6,4 @@ import Foundation struct Category: Decodable, Hashable { let name: String let savePath: String -} \ No newline at end of file +} diff --git a/qBitControl/Models/RSSNode.swift b/qBitControl/Models/RSSNode.swift index 82536cd..5098476 100644 --- a/qBitControl/Models/RSSNode.swift +++ b/qBitControl/Models/RSSNode.swift @@ -5,7 +5,37 @@ final class RSSNode: Decodable, Identifiable { var title = "RSS" var nodes: [RSSNode] = [] var feeds: [RSSFeed] = [] + + weak var parent: RSSNode? + func getPath() -> String { + if let parent = self.parent { + if parent.getPath().isEmpty { + return title + } + return "\(parent.getPath())\\\(title)" + } + if title == "RSS" { return "" } + return title + } + + func getNode(path: [String]) -> RSSNode? { + if path.count == 1 && path.first! == self.title { return self } + + var newPath = path + newPath.removeFirst() + + for node in nodes { + if newPath.first! == node.title { + return node.getNode(path: newPath) + } + } + + return nil + } + + init() { } + required init(from decoder: any Decoder) throws { let decoder = try decoder.singleValueContainer() @@ -16,6 +46,7 @@ final class RSSNode: Decodable, Identifiable { feeds.append(feed) case .node(let node): node.title = key + node.parent = self nodes.append(node) case .empty: continue diff --git a/qBitControl/ViewModels/RSSView/RSSNodeViewModel.swift b/qBitControl/ViewModels/RSSView/RSSNodeViewModel.swift new file mode 100644 index 0000000..d8e20e5 --- /dev/null +++ b/qBitControl/ViewModels/RSSView/RSSNodeViewModel.swift @@ -0,0 +1,20 @@ +import SwiftUI + +class RSSNodeViewModel: ObservableObject { + static public let shared = RSSNodeViewModel() + + @Published public var rssRootNode: RSSNode = .init() + + init() { + self.getRssRootNode() + } + + func getRssRootNode() { + qBittorrent.getRSSFeeds(completionHandler: { RSSNode in + DispatchQueue.main.async { + self.rssRootNode = RSSNode + } + }) + } +} + diff --git a/qBitControl/ViewModels/RSSView/RSSViewModel.swift b/qBitControl/ViewModels/RSSView/RSSViewModel.swift index 2b80d27..5cdf887 100644 --- a/qBitControl/ViewModels/RSSView/RSSViewModel.swift +++ b/qBitControl/ViewModels/RSSView/RSSViewModel.swift @@ -1,7 +1,8 @@ import SwiftUI class RSSViewModel: ObservableObject { - @Published public var RSSNode: RSSNode? + @Published public var RSSNode: RSSNode = .init() + @Published public var updateID: UUID = UUID() init() { self.getRSSFeed() @@ -11,6 +12,7 @@ class RSSViewModel: ObservableObject { qBittorrent.getRSSFeeds(withDate: true, completionHandler: { RSSNode in DispatchQueue.main.async { self.RSSNode = RSSNode + self.updateID = UUID() } }) } diff --git a/qBitControl/ViewModels/TorrentView/TorrentAddViewModel.swift b/qBitControl/ViewModels/TorrentView/TorrentAddViewModel.swift index 094db13..4cd4f94 100644 --- a/qBitControl/ViewModels/TorrentView/TorrentAddViewModel.swift +++ b/qBitControl/ViewModels/TorrentView/TorrentAddViewModel.swift @@ -64,7 +64,9 @@ class TorrentAddViewModel: ObservableObject { } func handleTorrentFile(fileURL: URL) -> Void { - if fileURL.pathExtension != "torrent" { return } + let isRemote = fileURL.scheme == "https" || fileURL.scheme == "http" + + if fileURL.pathExtension != "torrent" && !isRemote { return } let fileName = fileURL.lastPathComponent @@ -72,7 +74,7 @@ class TorrentAddViewModel: ObservableObject { self.fileNames.append(fileName) } - if fileURL.startAccessingSecurityScopedResource() || fileURL.scheme == "https" || fileURL.scheme == "http" { + if fileURL.startAccessingSecurityScopedResource() || isRemote { Task { do { let data = try Data(contentsOf: fileURL) @@ -129,7 +131,7 @@ class TorrentAddViewModel: ObservableObject { qBittorrent.getCategories(completionHandler: { response in DispatchQueue.main.async { // Append sorted list of Category objects to ensure "None" always appears at the top - self.categories.append(contentsOf: response.map { $1 }.sorted { $0.name < $1.name }) + self.categories = response.map { $1 }.sorted { $0.name < $1.name } } }) } diff --git a/qBitControl/Views/RSSViews/RSSFeedView.swift b/qBitControl/Views/RSSViews/RSSFeedView.swift index 6186229..1082be6 100644 --- a/qBitControl/Views/RSSViews/RSSFeedView.swift +++ b/qBitControl/Views/RSSViews/RSSFeedView.swift @@ -1,6 +1,8 @@ import SwiftUI struct RSSFeedView: View { + @ObservedObject var rssNodeViewModel = RSSNodeViewModel.shared + @State public var rssFeed: RSSFeed @State public var searchQuery: String = "" @@ -27,5 +29,6 @@ struct RSSFeedView: View { } .navigationTitle(rssFeed.title) .searchable(text: $searchQuery) + .onAppear { if self.rssFeed.title.isEmpty { rssNodeViewModel.getRssRootNode() } } } } diff --git a/qBitControl/Views/RSSViews/RSSNodeView.swift b/qBitControl/Views/RSSViews/RSSNodeView.swift index 652bb45..65d2ae8 100644 --- a/qBitControl/Views/RSSViews/RSSNodeView.swift +++ b/qBitControl/Views/RSSViews/RSSNodeView.swift @@ -1,28 +1,67 @@ import SwiftUI struct RSSNodeView: View { - @State public var rssNode: RSSNode + @State public var path: [String] + @ObservedObject var viewModel = RSSNodeViewModel.shared + var rssNode: RSSNode { viewModel.rssRootNode.getNode(path: path)! } + @State private var isAddFeedAlert: Bool = false + @State private var isAddFolderAlert: Bool = false + + @State private var newFeedURL = "" + @State private var newFolderName = "" + var body: some View { List { Section(header: sectionHeader()) { ForEach(rssNode.nodes, id: \.id) { node in NavigationLink { - RSSNodeView(rssNode: node) + RSSNodeView(path: path + [node.title]) } label: { Label(node.title, systemImage: "folder.fill") - }.disabled(node.nodes.isEmpty && node.feeds.isEmpty) + }//.disabled(node.nodes.isEmpty && node.feeds.isEmpty) } ForEach(rssNode.feeds, id: \.id) { feed in NavigationLink { RSSFeedView(rssFeed: feed) } label: { - Label(feed.title, systemImage: "dot.radiowaves.up.forward") + Label(feed.title.isEmpty ? "Feed" : feed.title, systemImage: "dot.radiowaves.up.forward") } } } - }.navigationTitle(rssNode.title) + }.navigationTitle(viewModel.rssRootNode.getNode(path: path)!.title) + .refreshable { refresh() } + .toolbar { toolbar() } + .alert("Add Feed", isPresented: $isAddFeedAlert, actions: { + VStack { + TextField("URL", text: $newFeedURL) + Button("Add") { + if self.newFeedURL.isEmpty { return } + var path = self.path + [rssNode.title, newFeedURL] + path.removeFirst() + qBittorrent.addRSSFeed(url: newFeedURL, path: path.joined(separator: "\\")) + newFeedURL = "" + refresh() + } + Button("Cancel", role: .cancel) {} + } + }).alert("Add Folder", isPresented: $isAddFolderAlert, actions: { + VStack { + TextField("Name", text: $newFolderName) + Button("Add") { + let path = rssNode.getPath() + if path.isEmpty { + qBittorrent.addRSSFolder(path: newFolderName) + } else { + qBittorrent.addRSSFolder(path: rssNode.getPath() + "\\" + newFolderName) + } + newFolderName = "" + refresh() + } + Button("Cancel", role: .cancel) {} + } + }) } func sectionHeader() -> Text { @@ -31,4 +70,18 @@ struct RSSNodeView: View { "\(!rssNode.feeds.isEmpty ? " • \(rssNode.feeds.count) Feeds" : "")" ) } + + func toolbar() -> some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Menu { + //Button { isAddFeedAlert = true } label: { Label("Add Feed", systemImage: "dot.radiowaves.up.forward") } + Button { isAddFolderAlert = true } label: { Label("Add Folder", systemImage: "folder.badge.plus") } + } label: { + Image(systemName: "plus") + } + } + } + + func refresh() { viewModel.getRssRootNode() } } + diff --git a/qBitControl/Views/RSSViews/RSSView.swift b/qBitControl/Views/RSSViews/RSSView.swift index b4585a1..0478d7e 100644 --- a/qBitControl/Views/RSSViews/RSSView.swift +++ b/qBitControl/Views/RSSViews/RSSView.swift @@ -6,15 +6,11 @@ import SwiftUI struct RSSView: View { - @ObservedObject private var viewModel = RSSViewModel() - var body: some View { VStack { - if let rssNode = viewModel.RSSNode { - NavigationStack { - RSSNodeView(rssNode: rssNode) - } + NavigationStack { + RSSNodeView(path: ["RSS"]) } - }.navigationTitle("Feeds") + } } } diff --git a/qBitControl/Views/TorrentViews/TorrentAddView.swift b/qBitControl/Views/TorrentViews/TorrentAddView.swift index e4aae47..fd34936 100644 --- a/qBitControl/Views/TorrentViews/TorrentAddView.swift +++ b/qBitControl/Views/TorrentViews/TorrentAddView.swift @@ -104,17 +104,23 @@ struct TorrentAddView: View { Group { Section(header: Text("Save Path")) { TextField("Path", text: $viewModel.savePath) } - Section(header: Text("Info")) { - Picker("Category", selection: $viewModel.category) { - ForEach(viewModel.categories, id: \.self) { category in - Text(category.name).tag(category.name) + Group { + Section(header: Text("Info")) { + Picker("Category", selection: $viewModel.category) { + if !viewModel.categories.isEmpty { + ForEach(viewModel.categories, id: \.self) { category in + Text(category.name).tag(category.name) + } + } + }.onChange(of: viewModel.category) { category in + if !viewModel.autoTmmEnabled { viewModel.savePath = category.savePath } + } + + Picker("Tags", selection: $viewModel.tags) { + if !viewModel.tags.isEmpty { + ForEach(viewModel.tagsArr, id: \.self) { tag in Text(tag).tag(tag) } + } } - }.onChange(of: viewModel.category) { category in - if !viewModel.autoTmmEnabled { viewModel.savePath = category.savePath } - } - - Picker("Tags", selection: $viewModel.tags) { - ForEach(viewModel.tagsArr, id: \.self) { tag in Text(tag).tag(tag) } } } diff --git a/qBitControlUITests/qBitControlUITests.swift b/qBitControlUITests/qBitControlUITests.swift new file mode 100644 index 0000000..60274c0 --- /dev/null +++ b/qBitControlUITests/qBitControlUITests.swift @@ -0,0 +1,38 @@ +// + +import XCTest + +final class qBitControlUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/qBitControlUITests/qBitControlUITestsLaunchTests.swift b/qBitControlUITests/qBitControlUITestsLaunchTests.swift new file mode 100644 index 0000000..1ff5a2a --- /dev/null +++ b/qBitControlUITests/qBitControlUITestsLaunchTests.swift @@ -0,0 +1,28 @@ +// + +import XCTest + +final class qBitControlUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +}