Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions Sources/NextcloudKit/Models/NKDataFileXML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ public class NKDataFileXML: NSObject {
</d:propertyupdate>
"""

let requestBodySystemTags =
"""
<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<d:propfind xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\" xmlns:nc=\"http://nextcloud.org/ns\">
<d:prop>
<oc:id />
<oc:display-name />
<nc:color />
</d:prop>
</d:propfind>
"""

func getRequestBodyFileListingFavorites(createProperties: [NKProperties]?, removeProperties: [NKProperties] = []) -> String {
let request = """
<?xml version=\"1.0\"?>
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/NextcloudKit/Models/NKFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
62 changes: 62 additions & 0 deletions Sources/NextcloudKit/Models/NKTag.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
276 changes: 276 additions & 0 deletions Sources/NextcloudKit/NextcloudKit+Tags.swift
Original file line number Diff line number Diff line change
@@ -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<Data>?,
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<Data>?,
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<Data>?,
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<Data>?,
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
))
}
}
}

}
Loading