diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceAvailableLabels.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceAvailableLabels.swift new file mode 100644 index 00000000..6643a682 --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceAvailableLabels.swift @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public struct NKGovernanceAvailableLabels: Codable, Sendable, Equatable, Hashable { + public let sensitivity: [NKGovernanceLabel] + public let retention: [NKGovernanceLabel] + public let hold: [NKGovernanceLabel] + + public init(sensitivity: [NKGovernanceLabel], retention: [NKGovernanceLabel], hold: [NKGovernanceLabel]) { + self.sensitivity = sensitivity + self.retention = retention + self.hold = hold + } + + enum CodingKeys: String, CodingKey { + case sensitivity, retention, hold + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sensitivity = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .sensitivity) ?? [] + retention = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .retention) ?? [] + hold = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .hold) ?? [] + } +} diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift new file mode 100644 index 00000000..81065beb --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public struct NKGovernanceLabel: Codable, Sendable, Equatable, Hashable { + public let id: String + public let name: String + public let priority: Int + public let description: String + public let color: String + public let isAssigned: Bool + + public init(id: String, name: String, priority: Int, description: String, color: String, isAssigned: Bool) { + self.id = id + self.name = name + self.priority = priority + self.description = description + self.color = color + self.isAssigned = isAssigned + } + + enum CodingKeys: String, CodingKey { + case id, name, priority, description, color, isAssigned + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + priority = try container.decodeIfPresent(Int.self, forKey: .priority) ?? 0 + description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" + + let rawColor = try container.decodeIfPresent(String.self, forKey: .color) ?? "" + color = rawColor.isEmpty || rawColor.hasPrefix("#") ? rawColor : "#\(rawColor)" + + isAssigned = try container.decodeIfPresent(Bool.self, forKey: .isAssigned) ?? false + } +} diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelType.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelType.swift new file mode 100644 index 00000000..2c4f2ce3 --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelType.swift @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public enum NKGovernanceLabelType: String, Sendable, Equatable, Hashable { + case sensitivity = "SENSITIVITY" + case retention = "RETENTION" + case hold = "HOLD" +} diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index a6c6ad70..de4e898d 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -133,6 +133,7 @@ public extension NextcloudKit { let recommendations: Recommendations? let termsOfService: TermsOfService? let clientIntegration: NKClientIntegration? + let governance: Governance? enum CodingKeys: String, CodingKey { case downloadLimit = "downloadlimit" @@ -147,8 +148,11 @@ public extension NextcloudKit { case recommendations case termsOfService = "terms_of_service" case clientIntegration = "client_integration" + case governance } + struct Governance: Codable {} + struct DownloadLimit: Codable { let enabled: Bool? let defaultLimit: Int? @@ -350,32 +354,6 @@ public extension NextcloudKit { struct Recommendations: Codable { let enabled: Bool? } - -// struct DeclarativeUI: Codable { -// let contextMenu: [[ContextMenuItem]] -// -// enum CodingKeys: String, CodingKey { -// case contextMenu = "context-menu" -// } -// } - -// -// struct DeclarativeUI: Codable { -// let contextMenus: [ContextMenu] -// -// enum CodingKeys: String, CodingKey { -// case contextMenus = "context-menu" -// } -// -// struct ContextMenu: Codable { -// let items -// } -// -// struct ContextMenuItem: Codable { -// let title: String -// let endpoint: String -// } -// } } } } @@ -435,6 +413,8 @@ public extension NextcloudKit { capabilities.activityEnabled = json.activity != nil capabilities.activity = json.activity?.apiv2 ?? [] + capabilities.governanceEnabled = json.governance != nil + capabilities.notification = json.notifications?.ocsendpoints ?? [] capabilities.filesUndelete = json.files?.undelete ?? false @@ -548,6 +528,7 @@ final public class NKCapabilities: Sendable { public var userStatusSupportsBusy: Bool = false public var externalSites: Bool = false public var activityEnabled: Bool = false + public var governanceEnabled: Bool = false public var groupfoldersEnabled: Bool = false // NC27 public var assistantEnabled: Bool = false // NC28 public var isLivePhotoServerAvailable: Bool = false // NC28 diff --git a/Sources/NextcloudKit/NextcloudKit+Governance.swift b/Sources/NextcloudKit/NextcloudKit+Governance.swift new file mode 100644 index 00000000..f9b474d1 --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+Governance.swift @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +public extension NextcloudKit { + /// Returns all labels the user may apply to an entity, grouped by type, in a single request. + func getGovernanceAvailableLabels(entityType: String = "FILES", + entityId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, labels: NKGovernanceAvailableLabels?, responseData: AFDataResponse?, error: NKError) { + await withCheckedContinuation { continuation in + let endpoint = governancePath(entityType: entityType, entityId: entityId) + "/available?format=json" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + continuation.resume(returning: (account, nil, nil, .urlError)) + } + } + + nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, 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) + options.queue.async { + continuation.resume(returning: (account, nil, response, error)) + } + case .success(let data): + if let result = try? JSONDecoder().decode(GovernanceOCS.self, from: data) { + options.queue.async { + continuation.resume(returning: (account, result.ocs.data, response, .success)) + } + } else { + options.queue.async { + continuation.resume(returning: (account, nil, response, .invalidData)) + } + } + } + } + } + } + + /// Applies a label to an entity. Only one label per type may be active (except hold, which allows multiple). + func setGovernanceLabel(entityType: String = "FILES", + entityId: String, + labelType: NKGovernanceLabelType, + labelId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + await applyGovernanceLabel(method: .post, entityType: entityType, entityId: entityId, labelType: labelType, labelId: labelId, account: account, options: options, taskHandler: taskHandler) + } + + /// Removes a label from an entity. + func removeGovernanceLabel(entityType: String = "FILES", + entityId: String, + labelType: NKGovernanceLabelType, + labelId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + await applyGovernanceLabel(method: .delete, entityType: entityType, entityId: entityId, labelType: labelType, labelId: labelId, account: account, options: options, taskHandler: taskHandler) + } + + private func governancePath(entityType: String, entityId: String) -> String { + "ocs/v2.php/apps/governance/v1/labels/\(entityType)/\(entityId)" + } + + private func applyGovernanceLabel(method: HTTPMethod, + entityType: String, + entityId: String, + labelType: NKGovernanceLabelType, + labelId: String, + account: String, + options: NKRequestOptions, + taskHandler: @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + await withCheckedContinuation { continuation in + let endpoint = governancePath(entityType: entityType, entityId: entityId) + "/\(labelType.rawValue)/\(labelId)?format=json" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + continuation.resume(returning: (account, nil, .urlError)) + } + } + + nkSession.sessionData.request(url, method: method, encoding: URLEncoding.default, headers: headers, 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) + options.queue.async { + continuation.resume(returning: (account, response, result)) + } + } + } + } +} + +private struct GovernanceOCS: Decodable { + let ocs: Inner + + struct Inner: Decodable { + let data: T + } +}