Skip to content

Commit

Permalink
Add 'Add Folder' Option to RSS View
Browse files Browse the repository at this point in the history
  • Loading branch information
Michael-128 committed Nov 7, 2024
1 parent a0c8070 commit ca16aa6
Show file tree
Hide file tree
Showing 14 changed files with 229 additions and 27 deletions.
7 changes: 7 additions & 0 deletions Localization/Localizations/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@
}
}
}
},
"Add Feed" : {

},
"Add Folder" : {

},
"Add Server" : {
"localizations" : {
Expand Down Expand Up @@ -1054,6 +1060,7 @@
}
},
"Feeds" : {
"extractionState" : "stale",
"localizations" : {
"ja" : {
"stringUnit" : {
Expand Down
4 changes: 4 additions & 0 deletions qBitControl.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
909EE5A12CDD3B4E002403DF /* RSSNodeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSNodeViewModel.swift; sourceTree = "<group>"; };
90AE89312CDB6EFC000E0276 /* RSSViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSViewModel.swift; sourceTree = "<group>"; };
90AE89332CDB7316000E0276 /* RSSNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSNode.swift; sourceTree = "<group>"; };
90AE89382CDBEC47000E0276 /* RSSNodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSNodeView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -415,6 +417,7 @@
90AE89302CDB6EF5000E0276 /* RSSView */ = {
isa = PBXGroup;
children = (
909EE5A12CDD3B4E002403DF /* RSSNodeViewModel.swift */,
90AE89312CDB6EFC000E0276 /* RSSViewModel.swift */,
);
path = RSSView;
Expand Down Expand Up @@ -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 */,
Expand Down
12 changes: 12 additions & 0 deletions qBitControl/Classes/qBittorrentClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion qBitControl/Models/Category.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ import Foundation
struct Category: Decodable, Hashable {
let name: String
let savePath: String
}
}
31 changes: 31 additions & 0 deletions qBitControl/Models/RSSNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions qBitControl/ViewModels/RSSView/RSSNodeViewModel.swift
Original file line number Diff line number Diff line change
@@ -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
}
})
}
}

4 changes: 3 additions & 1 deletion qBitControl/ViewModels/RSSView/RSSViewModel.swift
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -11,6 +12,7 @@ class RSSViewModel: ObservableObject {
qBittorrent.getRSSFeeds(withDate: true, completionHandler: { RSSNode in
DispatchQueue.main.async {
self.RSSNode = RSSNode
self.updateID = UUID()
}
})
}
Expand Down
8 changes: 5 additions & 3 deletions qBitControl/ViewModels/TorrentView/TorrentAddViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,17 @@ 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

DispatchQueue.main.async {
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)
Expand Down Expand Up @@ -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 }
}
})
}
Expand Down
3 changes: 3 additions & 0 deletions qBitControl/Views/RSSViews/RSSFeedView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import SwiftUI

struct RSSFeedView: View {
@ObservedObject var rssNodeViewModel = RSSNodeViewModel.shared

@State public var rssFeed: RSSFeed
@State public var searchQuery: String = ""

Expand All @@ -27,5 +29,6 @@ struct RSSFeedView: View {
}
.navigationTitle(rssFeed.title)
.searchable(text: $searchQuery)
.onAppear { if self.rssFeed.title.isEmpty { rssNodeViewModel.getRssRootNode() } }
}
}
63 changes: 58 additions & 5 deletions qBitControl/Views/RSSViews/RSSNodeView.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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() }
}

10 changes: 3 additions & 7 deletions qBitControl/Views/RSSViews/RSSView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
26 changes: 16 additions & 10 deletions qBitControl/Views/TorrentViews/TorrentAddView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}

Expand Down
38 changes: 38 additions & 0 deletions qBitControlUITests/qBitControlUITests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
Loading

0 comments on commit ca16aa6

Please sign in to comment.