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
+ ))
+ }
+ }
+ }
+
+}