diff --git a/Sources/NextcloudKit/Models/NKDataFileXML.swift b/Sources/NextcloudKit/Models/NKDataFileXML.swift index cdb27649..0d76e267 100644 --- a/Sources/NextcloudKit/Models/NKDataFileXML.swift +++ b/Sources/NextcloudKit/Models/NKDataFileXML.swift @@ -89,6 +89,18 @@ public class NKDataFileXML: NSObject { """ + let requestBodySystemTags = + """ + + + + + + + + + """ + func getRequestBodyFileListingFavorites(createProperties: [NKProperties]?, removeProperties: [NKProperties] = []) -> String { let request = """ @@ -452,11 +464,8 @@ public class NKDataFileXML: NSObject { file.lockTimeOut = file.lockTime?.addingTimeInterval(TimeInterval(lockTimeOut)) } - let tagsElements = propstat["d:prop", "nc:system-tags"] - for element in tagsElements["nc:system-tag"] { - guard let tag = element.text else { continue } - file.tags.append(tag) - } + let tags: [NKTag] = NKTag.parse(systemTagElements: propstat["d:prop", "nc:system-tags", "nc:system-tag"]) + file.tags.append(contentsOf: tags) // NC27 ----- if let latitude = propstat["d:prop", "nc:file-metadata-gps", "latitude"].double { diff --git a/Sources/NextcloudKit/Models/NKFile.swift b/Sources/NextcloudKit/Models/NKFile.swift index f048edac..53335296 100644 --- a/Sources/NextcloudKit/Models/NKFile.swift +++ b/Sources/NextcloudKit/Models/NKFile.swift @@ -56,7 +56,7 @@ public struct NKFile: Sendable { public var shareType: [Int] public var size: Int64 public var serverUrl: String - public var tags: [String] + public var tags: [NKTag] public var trashbinFileName: String public var trashbinOriginalLocation: String public var trashbinDeletionTime: Date @@ -128,7 +128,7 @@ public struct NKFile: Sendable { shareType: [Int] = [], size: Int64 = 0, serverUrl: String = "", - tags: [String] = [], + tags: [NKTag] = [], trashbinFileName: String = "", trashbinOriginalLocation: String = "", trashbinDeletionTime: Date = Date(), diff --git a/Sources/NextcloudKit/Models/NKTag.swift b/Sources/NextcloudKit/Models/NKTag.swift new file mode 100644 index 00000000..77a9bacb --- /dev/null +++ b/Sources/NextcloudKit/Models/NKTag.swift @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import SwiftyXMLParser + +public struct NKTag: Sendable, Equatable, Hashable { + public let id: String + public let name: String + public let color: String? + + public init(id: String, name: String, color: String?) { + self.id = id + self.name = name + self.color = color + } + + static func parse(xmlData: Data) -> [NKTag] { + let xml = XML.parse(xmlData) + let responses = xml["d:multistatus", "d:response"] + var tags: [NKTag] = [] + + for response in responses { + let propstat = response["d:propstat"][0] + guard let id = propstat["d:prop", "oc:id"].text, + let name = propstat["d:prop", "oc:display-name"].text else { + continue + } + + let color = normalizedColor(propstat["d:prop", "nc:color"].text) + + tags.append(NKTag(id: id, name: name, color: color)) + } + + return tags + } + + static func parse(systemTagElements: XML.Accessor) -> [NKTag] { + var tags: [NKTag] = [] + + for element in systemTagElements { + guard let name = element.text?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty else { + continue + } + + let id = element.attributes["oc:id"] ?? "" + let color = normalizedColor(element.attributes["nc:color"]) + tags.append(NKTag(id: id, name: name, color: color)) + } + + return tags + } + + private static func normalizedColor(_ rawValue: String?) -> String? { + guard let rawValue, !rawValue.isEmpty else { + return nil + } + return rawValue.hasPrefix("#") ? rawValue : "#\(rawValue)" + } +} diff --git a/Sources/NextcloudKit/NextcloudKit+Tags.swift b/Sources/NextcloudKit/NextcloudKit+Tags.swift new file mode 100644 index 00000000..876945e9 --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+Tags.swift @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +public extension NextcloudKit { + private var systemTagsPath: String { "/remote.php/dav/systemtags/" } + private var systemTagRelationsFilesPath: String { "/remote.php/dav/systemtags-relations/files/" } + + /// Returns the list of tags available for the account. + /// + /// - Parameters: + /// - account: The account performing the request. + /// - options: Optional request options. + /// - taskHandler: Callback for the underlying URL session task. + func getTags(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> ( + account: String, + tags: [NKTag]?, + responseData: AFDataResponse?, + error: NKError + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options, accept: "application/xml") else { + return ( + account: account, + tags: nil, + responseData: nil, + error: .urlError + ) + } + + let endpoint = nkSession.urlBase + systemTagsPath + guard let url = endpoint.encodedToUrl else { + return ( + account: account, + tags: nil, + responseData: nil, + error: .urlError + ) + } + + let method = HTTPMethod(rawValue: "PROPFIND") + headers.update(name: "Depth", value: "1") + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.httpBody = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).requestBodySystemTags.data(using: .utf8) + urlRequest.timeoutInterval = options.timeout + } catch { + return ( + account: account, + tags: nil, + responseData: nil, + error: NKError(error: error) + ) + } + + return await withCheckedContinuation { continuation in + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + continuation.resume(returning: ( + account: account, + tags: nil, + responseData: response, + error: error + )) + case .success: + guard let xmlData = response.data else { + return continuation.resume(returning: ( + account: account, + tags: nil, + responseData: response, + error: .invalidData + )) + } + let tags = NKTag.parse(xmlData: xmlData) + continuation.resume(returning: ( + account: account, + tags: tags, + responseData: response, + error: .success + )) + } + } + } + } + + /// Creates a new tag. + /// + /// - Parameters: + /// - name: Tag display name. + /// - account: Account performing the request. + /// - options: Optional request options. + /// - taskHandler: Callback for the underlying URL session task. + func createTag(name: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> ( + account: String, + responseData: AFDataResponse?, + error: NKError + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options, contentType: "application/json", accept: "application/json"), + let url = (nkSession.urlBase + systemTagsPath).encodedToUrl else { + return ( + account: account, + responseData: nil, + error: .urlError + ) + } + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .post, headers: headers) + urlRequest.timeoutInterval = options.timeout + let payload = ["name": name] + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: payload) + } catch { + return ( + account: account, + responseData: nil, + error: NKError(error: error) + ) + } + + return await withCheckedContinuation { continuation in + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + let result = self.evaluateResponse(response) + continuation.resume(returning: ( + account: account, + responseData: response, + error: result + )) + } + } + } + + /// Assigns a tag to a file by file id. + /// + /// - Parameters: + /// - tagId: The system tag id. + /// - fileId: The numeric file id. + /// - account: Account performing the request. + /// - options: Optional request options. + /// - taskHandler: Callback for the underlying URL session task. + func addTagToFile(tagId: String, + fileId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> ( + account: String, + responseData: AFDataResponse?, + error: NKError + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options, contentType: "application/json", accept: "application/json"), + let url = (nkSession.urlBase + systemTagRelationsFilesPath + fileId + "/" + tagId).encodedToUrl else { + return ( + account: account, + responseData: nil, + error: .urlError + ) + } + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .put, headers: headers) + urlRequest.timeoutInterval = options.timeout + urlRequest.httpBody = Data() + } catch { + return ( + account: account, + responseData: nil, + error: NKError(error: error) + ) + } + + return await withCheckedContinuation { continuation in + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + let result = self.evaluateResponse(response) + continuation.resume(returning: ( + account: account, + responseData: response, + error: result + )) + } + } + } + + /// Removes a tag assignment from a file. + /// + /// - Parameters: + /// - tagId: The system tag id. + /// - fileId: The numeric file id. + /// - account: Account performing the request. + /// - options: Optional request options. + /// - taskHandler: Callback for the underlying URL session task. + func removeTagFromFile(tagId: String, + fileId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> ( + account: String, + responseData: AFDataResponse?, + error: NKError + ) { + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options, contentType: "application/json", accept: "application/json"), + let url = (nkSession.urlBase + systemTagRelationsFilesPath + fileId + "/" + tagId).encodedToUrl else { + return ( + account: account, + responseData: nil, + error: .urlError + ) + } + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .delete, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return ( + account: account, + responseData: nil, + error: NKError(error: error) + ) + } + + return await withCheckedContinuation { continuation in + nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + let result = self.evaluateResponse(response) + continuation.resume(returning: ( + account: account, + responseData: response, + error: result + )) + } + } + } + +}