diff --git a/Sources/NextcloudKit/Models/NKOCSWrapper.swift b/Sources/NextcloudKit/Models/NKOCSWrapper.swift new file mode 100644 index 00000000..8cfc1004 --- /dev/null +++ b/Sources/NextcloudKit/Models/NKOCSWrapper.swift @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Generic `Decodable` envelope for OCS v2 responses. +/// Server replies follow `{ "ocs": { "meta": ..., "data": ... } }`. +public struct NKOCSWrapper: Decodable { + public let ocs: Inner + + public struct Inner: Decodable { + public let meta: NKOCSMeta + public let data: T + } +} + +/// OCS response metadata. `message` is optional per the OCS contract. +public struct NKOCSMeta: Decodable, Sendable { + public let status: String + public let statuscode: Int + public let message: String? +} diff --git a/Sources/NextcloudKit/Models/NKTermsOfService.swift b/Sources/NextcloudKit/Models/NKTermsOfService.swift index 61c17d77..05c57d7a 100644 --- a/Sources/NextcloudKit/Models/NKTermsOfService.swift +++ b/Sources/NextcloudKit/Models/NKTermsOfService.swift @@ -5,7 +5,10 @@ import Foundation public class NKTermsOfService: NSObject { - public var meta: Meta? + /// Source-compat alias for callers that still reference `NKTermsOfService.Meta`. + public typealias Meta = NKOCSMeta + + public var meta: NKOCSMeta? public var data: OCSData? public override init() { @@ -14,9 +17,9 @@ public class NKTermsOfService: NSObject { public func loadFromJSON(_ jsonData: Data) -> Bool { do { - let decodedResponse = try JSONDecoder().decode(OCSResponse.self, from: jsonData) - self.meta = decodedResponse.ocs.meta - self.data = decodedResponse.ocs.data + let decoded = try JSONDecoder().decode(NKOCSWrapper.self, from: jsonData) + self.meta = decoded.ocs.meta + self.data = decoded.ocs.data return true } catch { debugPrint("[DEBUG] decode error:", error) @@ -36,26 +39,10 @@ public class NKTermsOfService: NSObject { return data?.hasSigned ?? false } - public func getMeta() -> Meta? { + public func getMeta() -> NKOCSMeta? { return meta } - // MARK: - Codable - private class OCSResponse: Codable { - let ocs: OCS - } - - private class OCS: Codable { - let meta: Meta - let data: OCSData - } - - public class Meta: Codable { - public let status: String - public let statuscode: Int - public let message: String - } - public class OCSData: Codable { public let terms: [Term] public let languages: [String: String] diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift new file mode 100644 index 00000000..72bb55ee --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +#if DEBUG +import Foundation + +public extension NKUnifiedShare { + /// Sample share for SwiftUI previews and tests, covering all property variants. + static var mock: NKUnifiedShare { + NKUnifiedShare( + id: "preview-1", + owner: NKUnifiedShareOwner( + userId: "alice", + instance: nil, + displayName: "Alice", + icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) + ), + lastUpdated: 1_730_000_000_000, + state: .draft, + sources: [ + NKUnifiedShareSource( + class: "file", + value: "/Test.txt", + displayName: "Test.txt", + icon: NKUnifiedShareIcon(svg: nil, light: "https://example.com/light.png", dark: "https://example.com/dark.png") + ) + ], + recipients: [ +// NKUnifiedShareRecipient( +// class: "user", +// value: "bob", +// instance: nil, +// displayName: "Bob", +// icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) +// ) + ], + properties: [ + NKUnifiedSharePropertyDate( + class: "expiration", + displayName: "Expiration", + priority: 10, + required: false, + minDate: "2026-01-01" + ), + NKUnifiedSharePropertyEnum( + class: "role", + displayName: "Role", + priority: 20, + required: true, + value: "editor", + validValues: ["viewer", "editor"] + ), + NKUnifiedSharePropertyBoolean( + class: "download", + displayName: "Allow download", + priority: 30, + required: false, + value: "true" + ), + NKUnifiedSharePropertyPassword( + class: "password", + displayName: "Password", + hint: "Min 8 chars", + priority: 40, + required: false + ), + NKUnifiedSharePropertyString( + class: "note", + displayName: "Note", + priority: 50, + required: false, + value: "hi", + minLength: 0, + maxLength: 1000 + ) + ], + permissions: [ + NKUnifiedSharePermission( + class: "download", + displayName: "Allow download", + hint: nil, + category: nil, + enabled: true + ) + ] + ) + } +} +#endif diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift new file mode 100644 index 00000000..78c24be5 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A unified share returned by `ocs/v2.php/apps/sharing/api/v1/...`. +public final class NKUnifiedShare: Codable { + public let id: String + public let owner: NKUnifiedShareOwner + /// Unix time in milliseconds. + public let lastUpdated: Int64 + public let state: NKUnifiedShareState + public let sources: [NKUnifiedShareSource] + public let recipients: [NKUnifiedShareRecipient] + public let properties: [NKUnifiedShareProperty] + public let permissions: [NKUnifiedSharePermission] + + enum CodingKeys: String, CodingKey { + case id + case owner + case lastUpdated = "last_updated" + case state + case sources + case recipients + case properties + case permissions + } + + public init(id: String, + owner: NKUnifiedShareOwner, + lastUpdated: Int64, + state: NKUnifiedShareState, + sources: [NKUnifiedShareSource], + recipients: [NKUnifiedShareRecipient], + properties: [NKUnifiedShareProperty], + permissions: [NKUnifiedSharePermission]) { + self.id = id + self.owner = owner + self.lastUpdated = lastUpdated + self.state = state + self.sources = sources + self.recipients = recipients + self.properties = properties + self.permissions = permissions + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = try c.decode(String.self, forKey: .id) + self.owner = try c.decode(NKUnifiedShareOwner.self, forKey: .owner) + self.lastUpdated = try c.decode(Int64.self, forKey: .lastUpdated) + self.state = try c.decode(NKUnifiedShareState.self, forKey: .state) + self.sources = try c.decode([NKUnifiedShareSource].self, forKey: .sources) + self.recipients = try c.decode([NKUnifiedShareRecipient].self, forKey: .recipients) + self.permissions = try c.decode([NKUnifiedSharePermission].self, forKey: .permissions) + self.properties = try Self.decodeProperties(from: c) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(owner, forKey: .owner) + try c.encode(lastUpdated, forKey: .lastUpdated) + try c.encode(state, forKey: .state) + try c.encode(sources, forKey: .sources) + try c.encode(recipients, forKey: .recipients) + try c.encode(permissions, forKey: .permissions) + try c.encode(properties, forKey: .properties) + } + + /// Dispatch each `properties` element to the correct subclass based on its `type` discriminator. + /// Uses two independent unkeyed containers — one to peek, one to decode — so we can read the + /// `type` of every element without consuming the cursor we actually need for the concrete decode. + private static func decodeProperties(from c: KeyedDecodingContainer) throws -> [NKUnifiedShareProperty] { + var peek = try c.nestedUnkeyedContainer(forKey: .properties) + var real = try c.nestedUnkeyedContainer(forKey: .properties) + var result: [NKUnifiedShareProperty] = [] + + while !real.isAtEnd { + let holder = try peek.decode(TypeHolder.self) + switch holder.type { + case .date: + result.append(try real.decode(NKUnifiedSharePropertyDate.self)) + case .enumeration: + result.append(try real.decode(NKUnifiedSharePropertyEnum.self)) + case .boolean: + result.append(try real.decode(NKUnifiedSharePropertyBoolean.self)) + case .password: + result.append(try real.decode(NKUnifiedSharePropertyPassword.self)) + case .string: + result.append(try real.decode(NKUnifiedSharePropertyString.self)) + } + } + return result + } + + private struct TypeHolder: Decodable { + let type: NKUnifiedSharePropertyType + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift new file mode 100644 index 00000000..3a97304b --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Icon attached to an owner, source, recipient, or permission category. +/// +/// The OpenAPI declares this as `anyOf `. The two variants have disjoint key +/// sets (`svg` vs `light`+`dark`) so a single flat struct with all keys optional decodes either +/// shape cleanly with synthesized Codable. +public struct NKUnifiedShareIcon: Codable, Sendable { + /// Inline SVG body (IconSVG variant). + public let svg: String? + + /// Absolute URL to a light-theme image (IconURL variant). + public let light: String? + + /// Absolute URL to a dark-theme image (IconURL variant). + public let dark: String? +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift new file mode 100644 index 00000000..b81f7846 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Owner of a unified share. +public struct NKUnifiedShareOwner: Codable, Sendable { + public let userId: String + public let instance: String? + public let displayName: String + public let icon: NKUnifiedShareIcon + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case instance + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift new file mode 100644 index 00000000..c1e209ba --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A toggleable permission on a unified share (can view / can edit / can comment / …). +public struct NKUnifiedSharePermission: Codable, Sendable { + public let `class`: String + public let displayName: String + public let hint: String? + public let category: String? + public let enabled: Bool + + enum CodingKeys: String, CodingKey { + case `class` + case displayName = "display_name" + case hint + case category + case enabled + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift new file mode 100644 index 00000000..eb1baac9 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Property type discriminator. +public enum NKUnifiedSharePropertyType: String, Codable, Sendable { + case date + case enumeration = "enum" + case boolean + case password + case string +} + +/// Base class for a unified share property. +/// +/// Variant fields live on the concrete subclasses (`NKUnifiedSharePropertyDate`, …). When decoding +/// `NKUnifiedShare.properties`, the dispatch on `type` picks the right subclass. +public class NKUnifiedShareProperty: Codable { + public let `class`: String + public let displayName: String + public let hint: String? + public let priority: Int + public let required: Bool + public let value: String? + public let type: NKUnifiedSharePropertyType + + enum CodingKeys: String, CodingKey { + case `class` + case displayName = "display_name" + case hint + case priority + case required + case value + case type + } + + public init(class: String, displayName: String, hint: String?, priority: Int, required: Bool, value: String?, type: NKUnifiedSharePropertyType) { + self.class = `class` + self.displayName = displayName + self.hint = hint + self.priority = priority + self.required = required + self.value = value + self.type = type + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.class = try c.decode(String.self, forKey: .class) + self.displayName = try c.decode(String.self, forKey: .displayName) + self.hint = try c.decodeIfPresent(String.self, forKey: .hint) + self.priority = try c.decode(Int.self, forKey: .priority) + self.required = try c.decode(Bool.self, forKey: .required) + self.value = try c.decodeIfPresent(String.self, forKey: .value) + self.type = try c.decode(NKUnifiedSharePropertyType.self, forKey: .type) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(self.class, forKey: .class) + try c.encode(displayName, forKey: .displayName) + try c.encodeIfPresent(hint, forKey: .hint) + try c.encode(priority, forKey: .priority) + try c.encode(required, forKey: .required) + try c.encodeIfPresent(value, forKey: .value) + try c.encode(type, forKey: .type) + } +} + +/// Date-typed property; adds an optional valid range. +public final class NKUnifiedSharePropertyDate: NKUnifiedShareProperty { + public let minDate: String? + public let maxDate: String? + + enum DateKeys: String, CodingKey { + case minDate = "min_date" + case maxDate = "max_date" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil, minDate: String? = nil, maxDate: String? = nil) { + self.minDate = minDate + self.maxDate = maxDate + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .date) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: DateKeys.self) + self.minDate = try c.decodeIfPresent(String.self, forKey: .minDate) + self.maxDate = try c.decodeIfPresent(String.self, forKey: .maxDate) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: DateKeys.self) + try c.encodeIfPresent(minDate, forKey: .minDate) + try c.encodeIfPresent(maxDate, forKey: .maxDate) + } +} + +/// Enum-typed property; carries the allowed value set. +public final class NKUnifiedSharePropertyEnum: NKUnifiedShareProperty { + public let validValues: [String] + + enum EnumKeys: String, CodingKey { + case validValues = "valid_values" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil, validValues: [String]) { + self.validValues = validValues + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .enumeration) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: EnumKeys.self) + self.validValues = try c.decode([String].self, forKey: .validValues) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: EnumKeys.self) + try c.encode(validValues, forKey: .validValues) + } +} + +/// Boolean-typed property; no additional fields. +public final class NKUnifiedSharePropertyBoolean: NKUnifiedShareProperty { + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .boolean) + } + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } +} + +/// Password-typed property; no additional fields. +public final class NKUnifiedSharePropertyPassword: NKUnifiedShareProperty { + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .password) + } + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } +} + +/// String-typed property; adds optional length bounds. +public final class NKUnifiedSharePropertyString: NKUnifiedShareProperty { + public let minLength: Int? + public let maxLength: Int? + + enum StringKeys: String, CodingKey { + case minLength = "min_length" + case maxLength = "max_length" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil, minLength: Int? = nil, maxLength: Int? = nil) { + self.minLength = minLength + self.maxLength = maxLength + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .string) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: StringKeys.self) + self.minLength = try c.decodeIfPresent(Int.self, forKey: .minLength) + self.maxLength = try c.decodeIfPresent(Int.self, forKey: .maxLength) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: StringKeys.self) + try c.encodeIfPresent(minLength, forKey: .minLength) + try c.encodeIfPresent(maxLength, forKey: .maxLength) + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift new file mode 100644 index 00000000..23e95ab1 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +#if DEBUG +import Foundation + +public extension NKUnifiedShareRecipient { + /// Sample recipient for SwiftUI previews and tests. + static var mock: NKUnifiedShareRecipient { + NKUnifiedShareRecipient( + class: "user", + value: "bob", + instance: nil, + displayName: "Bob", + icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) + ) + } +} + +public extension Array where Element == NKUnifiedShareRecipient { + /// A few sample recipients, e.g. for autocomplete results. + static var mocks: [NKUnifiedShareRecipient] { + [ + NKUnifiedShareRecipient(class: "", value: "bob", instance: nil, displayName: "Bob", icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil)), + NKUnifiedShareRecipient(class: "", value: "team", instance: nil, displayName: "Team", icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil)), + NKUnifiedShareRecipient(class: "", value: "carol@example.com", instance: "example.com", displayName: "Carol (example.com)", icon: nil) + ] + } +} +#endif diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift new file mode 100644 index 00000000..3f36b478 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A recipient (user, group, federated user, public link, …) on a unified share. +public struct NKUnifiedShareRecipient: Codable, Sendable { + public let `class`: String + public let value: String + public let instance: String? + public let displayName: String + public let icon: NKUnifiedShareIcon? + + enum CodingKeys: String, CodingKey { + case `class` + case value + case instance + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift new file mode 100644 index 00000000..702f4ec2 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// An item being shared (file, folder, calendar, contact, …). +public struct NKUnifiedShareSource: Codable, Sendable { + public let `class`: String + public let value: String + public let displayName: String + public let icon: NKUnifiedShareIcon? + + enum CodingKeys: String, CodingKey { + case `class` + case value + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift new file mode 100644 index 00000000..7cfd5a35 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Lifecycle state of a unified share. +public enum NKUnifiedShareState: String, Codable, Sendable { + case active + case draft + case deleted +} diff --git a/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift b/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift index a3b88929..dd2f9cf8 100644 --- a/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift +++ b/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift @@ -34,7 +34,7 @@ public extension NextcloudKit { if meta.statuscode == 200 { options.queue.async { completion(account, tos, response, .success) } } else { - options.queue.async { completion(account, tos, response, NKError(errorCode: meta.statuscode, errorDescription: meta.message, responseData: jsonData)) } + options.queue.async { completion(account, tos, response, NKError(errorCode: meta.statuscode, errorDescription: meta.message ?? "", responseData: jsonData)) } } } else { options.queue.async { completion(account, nil, response, .invalidData) } diff --git a/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift new file mode 100644 index 00000000..4fdd7ddb --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift @@ -0,0 +1,448 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +/// Endpoints for the unified-sharing OCS API (`ocs/v2.php/apps/sharing/api/v1/...`). +public extension NextcloudKit { + // MARK: - List & search + + /// `GET /shares` — paginated list of shares the current user can see. + func listUnifiedShares(sourceClass: String? = nil, + lastShareID: String? = nil, + limit: Int? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, shares: [NKUnifiedShare]?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/shares" + 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 (account, nil, nil, .urlError) + } + + var parameters: [String: String] = [:] + if let sourceClass { parameters["sourceClass"] = sourceClass } + if let lastShareID { parameters["lastShareID"] = lastShareID } + if let limit { parameters["limit"] = String(limit) } + + let response = await nkSession.sessionData + .request(url, method: .get, parameters: parameters, encoding: URLEncoding.default, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShareList(response: response, account: account) + } + + /// `GET /recipients` — search recipients (users, groups, federated …) by free-text query. + func searchUnifiedShareRecipients(query: String, + recipientTypeClass: String? = nil, + limit: Int? = nil, + offset: Int? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, recipients: [NKUnifiedShareRecipient]?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/recipients" + 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 (account, nil, nil, .urlError) + } + + var parameters: [String: String] = ["query": query] + if let recipientTypeClass { parameters["recipientTypeClass"] = recipientTypeClass } + if let limit { parameters["limit"] = String(limit) } + if let offset { parameters["offset"] = String(offset) } + + let response = await nkSession.sessionData + .request(url, method: .get, parameters: parameters, encoding: URLEncoding.default, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShareRecipient]>.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + // MARK: - Single share lifecycle + + /// `POST /share` — create a new (draft) share. Returns the created share. + func createUnifiedShare(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share" + 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 (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .post, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// `POST /share/{id}` — fetch a specific share. Modelled as POST because the body carries + /// `secret` and free-form `arguments` per the OpenAPI. + func getUnifiedShare(id: String, + secret: String? = nil, + arguments: [String: Any]? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)" + 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 (account, nil, nil, .urlError) + } + + var body: [String: Any] = [:] + if let secret { body["secret"] = secret } + if let arguments { body["arguments"] = arguments } + + var urlRequest: URLRequest + + do { + urlRequest = try URLRequest(url: url, method: .post, headers: headers) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !body.isEmpty { + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + } + } catch { + return (account, nil, nil, NKError(error: error)) + } + + let response = await nkSession.sessionData + .request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// `DELETE /share/{id}` — 204 success, no response body. + func deleteUnifiedShare(id: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)" + 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 (account, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .delete, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success: + return (account, response, .success) + } + } + + // MARK: - Share mutations (return the updated share) + + /// `PUT /share/{id}/enabled` — toggle a permission. + func setUnifiedSharePermissionEnabled(id: String, + permissionClass: String, + enabled: Bool, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .put, + subpath: "enabled", + id: id, + body: ["class": permissionClass, "enabled": enabled], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/property` — set a property's value. + func setUnifiedShareProperty(id: String, + propertyClass: String, + value: String?, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var body: [String: Any] = ["class": propertyClass] + if let value { body["value"] = value } + return await mutateUnifiedShare(method: .put, + subpath: "property", + id: id, + body: body, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/state` — transition state (active/draft/deleted). + func setUnifiedShareState(id: String, + state: NKUnifiedShareState, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .put, + subpath: "state", + id: id, + body: ["state": state.rawValue], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `POST /share/{id}/recipient` — add a recipient. + func addUnifiedShareRecipient(id: String, + recipientClass: String, + value: String, + instance: String? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var body: [String: Any] = ["class": recipientClass, "value": value] + if let instance { body["instance"] = instance } + return await mutateUnifiedShare(method: .post, + subpath: "recipient", + id: id, + body: body, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `DELETE /share/{id}/recipient` — remove a recipient. Parameters travel as query string. + func removeUnifiedShareRecipient(id: String, + recipientClass: String, + value: String, + instance: String? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var query: [String: String] = ["class": recipientClass, "value": value] + if let instance { query["instance"] = instance } + return await mutateUnifiedShareWithQuery(method: .delete, + subpath: "recipient", + id: id, + query: query, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `POST /share/{id}/source` — add a source. + func addUnifiedShareSource(id: String, + sourceClass: String, + value: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .post, + subpath: "source", + id: id, + body: ["class": sourceClass, "value": value], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `DELETE /share/{id}/source` — remove a source. Parameters travel as query string. + func removeUnifiedShareSource(id: String, + sourceClass: String, + value: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShareWithQuery(method: .delete, + subpath: "source", + id: id, + query: ["class": sourceClass, "value": value], + account: account, + options: options, + taskHandler: taskHandler) + } + + // MARK: - Private helpers + + /// Shared body-carrying mutation that returns the updated share. + private func mutateUnifiedShare(method: HTTPMethod, + subpath: String, + id: String, + body: [String: Any], + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)/\(subpath)" + 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 (account, nil, nil, .urlError) + } + + var urlRequest: URLRequest + + do { + urlRequest = try URLRequest(url: url, method: method, headers: headers) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + return (account, nil, nil, NKError(error: error)) + } + + let response = await nkSession.sessionData + .request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// Shared query-carrying mutation (DELETE subresources) that returns the updated share. + private func mutateUnifiedShareWithQuery(method: HTTPMethod, + subpath: String, + id: String, + query: [String: String], + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)/\(subpath)" + 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 (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: method, parameters: query, encoding: URLEncoding.queryString, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// Decode an OCS response containing a single `Share`. + private func decodeUnifiedShare(response: AFDataResponse, + account: String + ) -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + /// Decode an OCS response containing an array of `Share`. + private func decodeUnifiedShareList(response: AFDataResponse, + account: String + ) -> (account: String, shares: [NKUnifiedShare]?, responseData: AFDataResponse?, error: NKError) { + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShare]>.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } +} diff --git a/Sources/NextcloudKitUI/Localizable.xcstrings b/Sources/NextcloudKitUI/Localizable.xcstrings index 98f718a0..81291223 100644 --- a/Sources/NextcloudKitUI/Localizable.xcstrings +++ b/Sources/NextcloudKitUI/Localizable.xcstrings @@ -1,8 +1,143 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "Add people" : { + "comment" : "Prompt to add recipients in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add people" + } + } + } + }, + "Anyone" : { + "comment" : "Audience option for anyone with link.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anyone" + } + } + } + }, + "Can edit" : { + "comment" : "Label for a permission option that allows editing files.", + "isCommentAutoGenerated" : true + }, + "Can view" : { + "comment" : "Default permission shown in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Can view" + } + } + } + }, + "Copy link" : { + "comment" : "Button title for copying the share link.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy link" + } + } + } + }, + "Custom permissions" : { + "comment" : "Label for a permission option that allows custom permissions.", + "isCommentAutoGenerated" : true + }, + "File drop" : { + "comment" : "Text displayed in a notification when a file drop is available.", + "isCommentAutoGenerated" : true + }, + "Invited" : { + "comment" : "Audience option for invited people only.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invited" + } + } + } + }, + "Note to recipients" : { + "comment" : "Optional note field title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note to recipients" + } + } + } + }, + "Participants" : { + "comment" : "Label above the permission selector in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Participants" + } + } + } + }, + "Send" : { + "comment" : "Primary action button title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + } + } + }, + "Settings" : { + "comment" : "Settings section title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "Share Abc.txt" : { + "comment" : "Title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share Abc.txt" + } + } + } + }, + "Test" : { + "comment" : "A row of settings options for a file share.", + "isCommentAutoGenerated" : true + }, "Accounts" : { "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts" + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -105,12 +240,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accounts" - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -656,6 +785,12 @@ "Accounts from other Apps" : { "comment" : "Button label\nNavigation bar title", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts from other Apps" + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -758,12 +893,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accounts from other Apps" - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -1309,6 +1438,12 @@ "Login Failed" : { "comment" : "Alert title", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login Failed" + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -1411,12 +1546,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Login Failed" - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -1962,6 +2091,12 @@ "OK" : { "comment" : "Button label for error alert dismissal.", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -2064,12 +2199,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -2615,6 +2744,12 @@ "Scan QR Code" : { "comment" : "Button label\nNavigation bar title", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan QR Code" + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -2717,12 +2852,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scan QR Code" - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -3268,6 +3397,12 @@ "Server Address" : { "comment" : "Label for text field.", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server Address" + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -3370,12 +3505,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server Address" - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -3921,6 +4050,12 @@ "The address of your Nextcloud web interface when you open it in your browser." : { "comment" : "Label below the server address field in the login view.", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The address of your Nextcloud web interface when you open it in your browser." + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -4023,12 +4158,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The address of your Nextcloud web interface when you open it in your browser." - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -4574,6 +4703,12 @@ "The entered server address is invalid." : { "comment" : "This is an error message.", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The entered server address is invalid." + } + }, "af" : { "stringUnit" : { "state" : "translated", @@ -4676,12 +4811,6 @@ "value" : "" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The entered server address is invalid." - } - }, "en_GB" : { "stringUnit" : { "state" : "translated", @@ -5226,4 +5355,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift new file mode 100644 index 00000000..dd8470af --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +enum UnifiedShareViewState { + case loading + case shareUpdated(share: NKUnifiedShare) + case error(Error) +} + +@MainActor +@Observable +public class UnifiedShareEditModel { + var state: UnifiedShareViewState = .loading + /// Recipient autocomplete results — coexist with a loaded share, so kept out of `state`. + var recipientResults: [NKUnifiedShareRecipient] = [] + let account: String + + init(account: String) { + self.account = account + } + +#if DEBUG + /// Preview-only initializer that starts in a given state. + init(account: String, state: UnifiedShareViewState, recipientResults: [NKUnifiedShareRecipient] = []) { + self.account = account + self.state = state + self.recipientResults = recipientResults + } +#endif + + func createShare() { + Task { + let result = await NextcloudKit.shared.createUnifiedShare(account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } + + func searchRecipients(query: String) { + guard !query.isEmpty else { + recipientResults = [] + return + } + + Task { + let result = await NextcloudKit.shared.searchUnifiedShareRecipients(query: query, account: account) + + recipientResults = result.recipients ?? [] + } + } + + func deleteShare(share: NKUnifiedShare) { + Task { + await NextcloudKit.shared.deleteUnifiedShare(id: share.id, account: account) + } + } + + func addRecipient(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + Task { + let result = await NextcloudKit.shared.addUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } +} + diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift new file mode 100644 index 00000000..0d9d7aa6 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +/// View used for Unified Sharing. +public struct UnifiedShareEditView: View { + let fileName: String + let account: String + @State private var model: UnifiedShareEditModel + + @State private var shareeType: ShareeType = .invited + @State private var permission: Permission = .canView + @State private var isSettingsExpanded = true + @State private var recipients = "" + @State private var note = "" + @Environment(\.colorScheme) private var colorScheme + + public init(fileName: String, account: String) { + self.fileName = fileName + self.account = account + model = UnifiedShareEditModel(account: account) + } + + init(fileName: String, model: UnifiedShareEditModel) { + self.fileName = fileName + self.account = model.account + self.model = model + } + + public var body: some View { + ZStack { + switch model.state { + case .loading: + ProgressView() + case .shareUpdated(let share): + + Form { +// VStack(alignment: .leading, spacing: 24) { + Section { + Text(String(localized: "Share \(fileName)")) + .font(.title) + // .foregroundStyle(.primary) + + } + + Section { + shareeTypePicker + + // VStack(spacing: 18) { + if shareeType == .invited && share.recipients.isEmpty { + TextField( + String(localized: "Add people"), + text: $recipients + ) + .onChange(of: recipients) { + model.searchRecipients(query: recipients) + } + // Publish the field's frame so the dropdown can be drawn outside the Form. + .anchorPreference(key: AddPeopleFieldAnchorKey.self, value: .bounds) { $0 } + + } else if let recipient = share.recipients.first { + Text(recipient.displayName) + } + + permissionField + } + settingsRow + + TextField( + String(localized: "Note to recipients"), + text: $note, + axis: .vertical + ) + + actionButtons +// } + + } +//// .padding(.horizontal, 26) +// .padding(.top, 10) + .onDisappear { + model.deleteShare(share: share) + } + .navigationTitle("Share") + // Draw the dropdown above the Form, anchored just beneath the field, so the + // Form's row clipping can't cut it off. + .overlayPreferenceValue(AddPeopleFieldAnchorKey.self) { anchor in + GeometryReader { proxy in + if let anchor, !model.recipientResults.isEmpty { + let frame = proxy[anchor] + + recipientDropdown(share: share) + .frame(width: frame.width) + .offset(x: frame.minX, y: frame.maxY + 4) + } + } + } + + case .error(let error): + Text(error.localizedDescription) + } + + // } + + + } + .task { + model.createShare() + } + + Spacer() +} + + private var shareeTypePicker: some View { + Picker("", selection: $shareeType) { + Text(String(localized: "Invited People")) + .tag(ShareeType.invited) + + Text(String(localized: "Anyone")) + .tag(ShareeType.anyone) + } + .pickerStyle(.segmented) + .listRowSeparator(.hidden) + + } + + private var permissionField: some View { +// LabeledContent(String(localized: shareeType == .anyone ? "Anyone with the link" : "Participants")) { + Picker(String(localized: "Participants"), selection: $permission) { + ForEach(Permission.allCases) { permission in + Text(permission.localizedTitle) + .tag(permission) + } + } + .pickerStyle(.menu) +// } + } + + private var settingsRow: some View { + DisclosureGroup(isExpanded: $isSettingsExpanded) { + Text("Test") + Text("Test") + } label: { + Text(String(localized: "Settings")) + } + } + + private func recipientDropdown(share: NKUnifiedShare) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.recipientResults, id: \.value) { recipient in + Button { + model.addRecipient(share: share, recipient: recipient) + } label: { + HStack(spacing: 10) { + if let icon = recipient.icon { + recipientIcon(icon) + } + + Text(recipient.displayName) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if recipient.value != model.recipientResults.last?.value { + Divider() + } + } + } + } + .frame(height: dropdownHeight) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.quaternary) + } + .shadow(radius: 4, y: 2) + } + + /// Height of the suggestions dropdown: one row each, capped so it stays a dropdown. + private var dropdownHeight: CGFloat { + min(CGFloat(model.recipientResults.count) * 44, 220) + } + + /// Renders the icon's URL variant (color-scheme aware). Inline SVG isn't natively renderable. + @ViewBuilder + private func recipientIcon(_ icon: NKUnifiedShareIcon) -> some View { + if let urlString = (colorScheme == .dark ? icon.dark : icon.light) ?? icon.light ?? icon.dark, + let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + } + .frame(width: 24, height: 24) + .clipShape(Circle()) + } + } + +// private func selectRecipient(_ recipient: NKUnifiedShareRecipient) { +// recipients = "" +// } + + private var actionButtons: some View { + HStack(spacing: 16) { + Button(String(localized: "Copy link")) { + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + + Button(String(localized: "Send")) { + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + .padding(.top, 18) + } +} + +/// Carries the "Add people" field's frame up to the ZStack so the dropdown can sit beneath it. +private struct AddPeopleFieldAnchorKey: PreferenceKey { + static let defaultValue: Anchor? = nil + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = value ?? nextValue() + } +} + +private extension UnifiedShareEditView { + enum ShareeType { + case invited + case anyone + } + + enum Permission: CaseIterable, Identifiable { + case canView + case canEdit + case fileDrop + case customPermissions + + var id: Self { + self + } + + var localizedTitle: String { + switch self { + case .canView: + String(localized: "Can view") + case .canEdit: + String(localized: "Can edit") + case .fileDrop: + String(localized: "File drop") + case .customPermissions: + String(localized: "Custom permissions") + } + } + } +} + +#Preview { + UnifiedShareEditView( + fileName: "Test.txt", + model: UnifiedShareEditModel(account: "", state: .shareUpdated(share: .mock), recipientResults: .mocks) + ) +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift new file mode 100644 index 00000000..a7cb5fef --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct UnifiedShareView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + UnifiedShareView() +} diff --git a/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift b/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift new file mode 100644 index 00000000..25b1eba4 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import NextcloudKit + +/// Verifies that `NKUnifiedShare` decodes through `NKOCSWrapper` and that the polymorphic +/// `properties` array yields the right concrete `NKUnifiedShareProperty` subclass per element. +@Suite("Unified share Codable") +struct UnifiedShareDecodingTests { + private func decodeShare(json: String) throws -> NKUnifiedShare { + let data = Data(json.utf8) + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + return wrap.ocs.data + } + + @Test("Decodes all five property variants into the matching subclass") + func decodesAllPropertyVariants() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s1", + "owner": { + "user_id": "alice", + "instance": null, + "display_name": "Alice", + "icon": { "svg": "" } + }, + "last_updated": 1730000000000, + "state": "active", + "sources": [], + "recipients": [], + "permissions": [], + "properties": [ + { + "class": "expiration", + "display_name": "Expiration", + "hint": null, + "priority": 10, + "required": false, + "value": null, + "type": "date", + "min_date": "2026-01-01", + "max_date": null + }, + { + "class": "role", + "display_name": "Role", + "hint": null, + "priority": 20, + "required": true, + "value": "editor", + "type": "enum", + "valid_values": ["viewer", "editor"] + }, + { + "class": "download", + "display_name": "Allow download", + "hint": null, + "priority": 30, + "required": false, + "value": "true", + "type": "boolean" + }, + { + "class": "password", + "display_name": "Password", + "hint": "Min 8 chars", + "priority": 40, + "required": false, + "value": null, + "type": "password" + }, + { + "class": "note", + "display_name": "Note", + "hint": null, + "priority": 50, + "required": false, + "value": "hi", + "type": "string", + "min_length": 0, + "max_length": 1000 + } + ] + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.id == "s1") + #expect(share.state == .active) + #expect(share.lastUpdated == 1_730_000_000_000) + #expect(share.properties.count == 5) + + let p0 = try #require(share.properties[0] as? NKUnifiedSharePropertyDate) + #expect(p0.type == .date) + #expect(p0.minDate == "2026-01-01") + #expect(p0.maxDate == nil) + + let p1 = try #require(share.properties[1] as? NKUnifiedSharePropertyEnum) + #expect(p1.type == .enumeration) + #expect(p1.validValues == ["viewer", "editor"]) + + let p2 = try #require(share.properties[2] as? NKUnifiedSharePropertyBoolean) + #expect(p2.type == .boolean) + #expect(p2.value == "true") + + let p3 = try #require(share.properties[3] as? NKUnifiedSharePropertyPassword) + #expect(p3.type == .password) + #expect(p3.hint == "Min 8 chars") + + let p4 = try #require(share.properties[4] as? NKUnifiedSharePropertyString) + #expect(p4.type == .string) + #expect(p4.minLength == 0) + #expect(p4.maxLength == 1000) + } + + @Test("Decodes both Icon shapes (svg / light+dark)") + func decodesIconVariants() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s2", + "owner": { + "user_id": "bob", + "instance": null, + "display_name": "Bob", + "icon": { "svg": "" } + }, + "last_updated": 0, + "state": "draft", + "sources": [ + { + "class": "file", + "value": "/foo.txt", + "display_name": "foo.txt", + "icon": { "light": "https://x/light.png", "dark": "https://x/dark.png" } + } + ], + "recipients": [], + "permissions": [], + "properties": [] + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.owner.icon.svg == "") + #expect(share.owner.icon.light == nil) + let source = try #require(share.sources.first) + #expect(source.icon?.svg == nil) + #expect(source.icon?.light == "https://x/light.png") + #expect(source.icon?.dark == "https://x/dark.png") + } + + @Test("Decodes a list response via NKOCSWrapper<[NKUnifiedShare]>") + func decodesShareListEnvelope() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200, "message": "OK" }, + "data": [] + } + } + """ + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShare]>.self, from: Data(json.utf8)) + #expect(wrap.ocs.meta.statuscode == 200) + #expect(wrap.ocs.meta.message == "OK") + #expect(wrap.ocs.data.isEmpty) + } +}