diff --git a/Package.resolved b/Package.resolved index d1edc639..cdcc1dd8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire", "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" } }, { 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..f362f1a4 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift @@ -0,0 +1,80 @@ +// 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: .mocks, + 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: "read", sourceClass: nil, displayName: "View files", hint: nil, priority: 1, presets: ["viewer", "editor"], enabled: true), + NKUnifiedSharePermission(class: "update", sourceClass: nil, displayName: "Edit files", hint: nil, priority: 2, presets: ["editor"], enabled: false), + NKUnifiedSharePermission(class: "share", sourceClass: nil, displayName: "Share with others", hint: nil, priority: 3, presets: ["editor"], enabled: false), + NKUnifiedSharePermission(class: "download", sourceClass: nil, displayName: "Download files", hint: nil, priority: 4, presets: ["viewer", "editor"], enabled: true) + ], + permissionPreset: nil + ) + } +} +#endif diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift new file mode 100644 index 00000000..d30a5fd5 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift @@ -0,0 +1,108 @@ +// 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, Identifiable { + 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] + /// Currently-applied permission preset class, if any. + public let permissionPreset: String? + + enum CodingKeys: String, CodingKey { + case id + case owner + case lastUpdated = "last_updated" + case state + case sources + case recipients + case properties + case permissions + case permissionPreset = "permission_preset" + } + + public init(id: String, + owner: NKUnifiedShareOwner, + lastUpdated: Int64, + state: NKUnifiedShareState, + sources: [NKUnifiedShareSource], + recipients: [NKUnifiedShareRecipient], + properties: [NKUnifiedShareProperty], + permissions: [NKUnifiedSharePermission], + permissionPreset: String? = nil) { + self.id = id + self.owner = owner + self.lastUpdated = lastUpdated + self.state = state + self.sources = sources + self.recipients = recipients + self.properties = properties + self.permissions = permissions + self.permissionPreset = permissionPreset + } + + 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.permissionPreset = try c.decodeIfPresent(String.self, forKey: .permissionPreset) + 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) + try c.encodeIfPresent(permissionPreset, forKey: .permissionPreset) + } + + /// 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..c3b3b20e --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift @@ -0,0 +1,27 @@ +// 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 sourceClass: String? + public let displayName: String + public let hint: String? + public let priority: Int + /// Class identifiers of the permission presets this permission belongs to. + public let presets: [String] + public let enabled: Bool + + enum CodingKeys: String, CodingKey { + case `class` + case sourceClass = "source_class" + case displayName = "display_name" + case hint + case priority + case presets + case enabled + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermissionPreset.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermissionPreset.swift new file mode 100644 index 00000000..efbf903b --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermissionPreset.swift @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A selectable permission preset advertised by the sharing capability. +public struct NKUnifiedSharePermissionPreset: Codable, Sendable { + public let `class`: String + public let displayName: String + public let hint: String? + + public init(class: String, displayName: String, hint: String? = nil) { + self.class = `class` + self.displayName = displayName + self.hint = hint + } + + enum CodingKeys: String, CodingKey { + case `class` + case displayName = "display_name" + case hint + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift new file mode 100644 index 00000000..60b2ab90 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift @@ -0,0 +1,185 @@ +// 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 advanced: 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 advanced + case value + case type + } + + public init(class: String, displayName: String, hint: String?, priority: Int, required: Bool, advanced: Bool, value: String?, type: NKUnifiedSharePropertyType) { + self.class = `class` + self.displayName = displayName + self.hint = hint + self.priority = priority + self.required = required + self.advanced = advanced + 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.advanced = try c.decode(Bool.self, forKey: .advanced) + 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.encode(advanced, forKey: .advanced) + 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, advanced: Bool = false, 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, advanced: advanced, 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, advanced: Bool = false, value: String? = nil, validValues: [String]) { + self.validValues = validValues + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, 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, advanced: Bool = false, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, 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, advanced: Bool = false, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, 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, advanced: Bool = false, 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, advanced: advanced, 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..dece2b8f --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift @@ -0,0 +1,33 @@ +// 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), + secret: Secret(updatable: false), + initiator: 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), secret: .init(updatable: false), initiator: nil), + NKUnifiedShareRecipient(class: "", value: "team", instance: nil, displayName: "Team", icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil), secret: .init(updatable: false), initiator: nil), + NKUnifiedShareRecipient(class: "", value: "carol@example.com", instance: "example.com", displayName: "Carol (example.com)", icon: nil, secret: .init(updatable: true, url: "https://example.com/s/abc"), initiator: nil) + ] + } +} +#endif diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift new file mode 100644 index 00000000..1aa640c6 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift @@ -0,0 +1,39 @@ +// 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? + public let secret: Secret + public let initiator: NKUnifiedShareOwner? + + enum CodingKeys: String, CodingKey { + case `class` + case value + case instance + case displayName = "display_name" + case icon + case secret + case initiator + } + + /// A recipient's secret; `url` carries the public link when present. + public struct Secret: Codable, Sendable { + public let updatable: Bool + public let value: String? + public let url: String? + + public init(updatable: Bool, value: String? = nil, url: String? = nil) { + self.updatable = updatable + self.value = value + self.url = url + } + } +} 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/NKUnifiedShareSourceType.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSourceType.swift new file mode 100644 index 00000000..79c3a6ef --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSourceType.swift @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A source type advertised by the sharing capability. +public struct NKUnifiedShareSourceType: Codable, Sendable { + public let `class`: String + + public init(class: String) { + self.class = `class` + } + + enum CodingKeys: String, CodingKey { + case `class` + } +} 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/Models/UnifiedSharing/NKUnifiedSharingCapabilities.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharingCapabilities.swift new file mode 100644 index 00000000..91185a2f --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharingCapabilities.swift @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// The `sharing` block of the server capabilities, describing unified-sharing support. +public struct NKUnifiedSharingCapabilities: Codable, Sendable { + public let apiVersions: [String] + public let sourceTypes: [NKUnifiedShareSourceType] + public let permissionPresets: [NKUnifiedSharePermissionPreset] + + public init(apiVersions: [String], sourceTypes: [NKUnifiedShareSourceType], permissionPresets: [NKUnifiedSharePermissionPreset]) { + self.apiVersions = apiVersions + self.sourceTypes = sourceTypes + self.permissionPresets = permissionPresets + } + + enum CodingKeys: String, CodingKey { + case apiVersions = "api_versions" + case sourceTypes = "source_types" + case permissionPresets = "permission_presets" + } +} 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..3f49deed --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift @@ -0,0 +1,586 @@ +// 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(filterSourceTypeClass: String? = nil, + filterSourceTypeValue: 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 filterSourceTypeClass { parameters["filterSourceTypeClass"] = filterSourceTypeClass } + if let filterSourceTypeValue { parameters["filterSourceTypeValue"] = filterSourceTypeValue } + 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, + recipientTypeClasses: [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) + } + + // Build the query manually so `recipientTypeClasses[]` can repeat once per element + // (a plain [String: String] can't hold an array, and [String: Any] isn't Sendable). + guard let baseURL = try? url.asURL(), + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return (account, nil, nil, .urlError) + } + + var queryItems: [URLQueryItem] = [URLQueryItem(name: "query", value: query)] + recipientTypeClasses?.forEach { queryItems.append(URLQueryItem(name: "recipientTypeClasses[]", value: $0)) } + if let limit { queryItems.append(URLQueryItem(name: "limit", value: String(limit))) } + if let offset { queryItems.append(URLQueryItem(name: "offset", value: String(offset))) } + components.queryItems = queryItems + + guard let requestURL = components.url else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(requestURL, method: .get, 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)) + } + } + } + + /// `GET /secret` — generate a new server-side secret (returns the secret string). + func generateUnifiedShareSecret(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, secret: String?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/secret" + 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: .get, 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.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: - Capabilities + + /// `GET /cloud/capabilities` — the unified-sharing capability block, or `nil` if the server + /// doesn't advertise it. + func getUnifiedSharingCapabilities(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, capabilities: NKUnifiedSharingCapabilities?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/cloud/capabilities" + 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: .get, 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.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.capabilities.sharing, 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}/permission` — toggle a permission. + func setUnifiedSharePermission(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: "permission", + id: id, + body: ["class": permissionClass, "enabled": enabled], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/permission/preset` — apply a permission preset. + func setUnifiedSharePermissionPreset(id: String, + permissionPresetClass: 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: .put, + subpath: "permission/preset", + id: id, + body: ["permissionPresetClass": permissionPresetClass], + 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) { + // Send an explicit null value to clear a property (e.g. removing an expiration date). + let body: [String: Any] = ["class": propertyClass, "value": value ?? NSNull()] + 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) + } + + /// `PUT /share/{id}/recipient/secret` — set/rotate a recipient's secret. + func setUnifiedShareRecipientSecret(id: String, + recipientClass: String, + value: String, + secret: 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, "secret": secret] + if let instance { body["instance"] = instance } + return await mutateUnifiedShare(method: .put, + subpath: "recipient/secret", + id: id, + body: body, + 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)) + } + } + } + + /// `ocs.data` shape of `/cloud/capabilities`, narrowed to the unified-sharing block. + private struct CapabilitiesEnvelope: Decodable { + let capabilities: Capabilities + + struct Capabilities: Decodable { + let sharing: NKUnifiedSharingCapabilities? + } + } +} 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..7e3fff16 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift @@ -0,0 +1,265 @@ +// 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 { + /// Server class for a file/folder share source. + static let nodeSourceClass = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" + /// Server class for a public-link (token) recipient. + static let tokenRecipientClass = "OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType" + + var state: UnifiedShareViewState = .loading + /// Recipient autocomplete results — coexist with a loaded share, so kept out of `state`. + var recipientResults: [NKUnifiedShareRecipient] = [] + /// Selectable permission presets advertised by the server's sharing capability. + var permissionPresets: [NKUnifiedSharePermissionPreset] = [] + /// Last property-update error text, keyed by property class (shown under the field). + var propertyErrors: [String: String] = [:] + /// Set once the draft has been activated (sent), so the sheet can dismiss. + var didActivate = false + let account: String + /// Globally-unique id of the file/folder being shared (attached as the share source). + let sourceId: String? + + init(account: String, sourceId: String? = nil) { + self.account = account + self.sourceId = sourceId + } + + /// Edit an already-existing share (from the list) rather than creating a new draft. + init(account: String, existingShare: NKUnifiedShare) { + self.account = account + self.sourceId = nil + self.state = .shareUpdated(share: existingShare) + } + +#if DEBUG + /// Preview-only initializer that starts in a given state. + init(account: String, + state: UnifiedShareViewState, + recipientResults: [NKUnifiedShareRecipient] = [], + permissionPresets: [NKUnifiedSharePermissionPreset] = []) { + self.account = account + self.sourceId = nil + self.state = state + self.recipientResults = recipientResults + self.permissionPresets = permissionPresets + } +#endif + + func loadCapabilities() { + Task { + let result = await NextcloudKit.shared.getUnifiedSharingCapabilities(account: account) + permissionPresets = result.capabilities?.permissionPresets ?? [] + } + } + + func setPermissionPreset(share: NKUnifiedShare, presetClass: String) { + Task { + let result = await NextcloudKit.shared.setUnifiedSharePermissionPreset(id: share.id, permissionPresetClass: presetClass, account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } + + func setPermission(share: NKUnifiedShare, permissionClass: String, enabled: Bool) { + Task { + let result = await NextcloudKit.shared.setUnifiedSharePermission(id: share.id, permissionClass: permissionClass, enabled: enabled, account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } + + func createShare() { + Task { + let result = await NextcloudKit.shared.createUnifiedShare(account: account) + guard var share = result.share else { + state = .error(result.error) + return + } + + // Point the draft at the actual file/folder being shared. + if let sourceId, !sourceId.isEmpty { + let sourceResult = await NextcloudKit.shared.addUnifiedShareSource(id: share.id, sourceClass: Self.nodeSourceClass, value: sourceId, account: account) + if let updated = sourceResult.share { + share = updated + } + } + + state = .shareUpdated(share: share) + } + } + + /// Switch between an invited-people share and a public-link (token) share. + func setShareeType(share: NKUnifiedShare, anyone: Bool) { + Task { + var current = share + + if anyone { + for recipient in current.recipients where recipient.class != Self.tokenRecipientClass { + current = await removingRecipient(from: current, recipient: recipient) ?? current + } + + if !current.recipients.contains(where: { $0.class == Self.tokenRecipientClass }) { + let result = await NextcloudKit.shared.addUnifiedShareRecipient(id: current.id, recipientClass: Self.tokenRecipientClass, value: UUID().uuidString, account: account) + if let updated = result.share { + current = updated + } + } + } else { + for recipient in current.recipients where recipient.class == Self.tokenRecipientClass { + current = await removingRecipient(from: current, recipient: recipient) ?? current + } + } + + state = .shareUpdated(share: current) + } + } + + private func removingRecipient(from share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) async -> NKUnifiedShare? { + let result = await NextcloudKit.shared.removeUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, instance: recipient.instance, account: account) + return result.share + } + + /// Return the public link, activating the share first to mint it if needed. + func prepareLinkForCopy(share: NKUnifiedShare) async -> String? { + if let url = share.recipients.compactMap({ $0.secret.url }).first { + return url + } + + let result = await NextcloudKit.shared.setUnifiedShareState(id: share.id, state: .active, account: account) + guard let updated = result.share else { + state = .error(result.error) + return nil + } + + state = .shareUpdated(share: updated) + return updated.recipients.compactMap { $0.secret.url }.first + } + + 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) + } + } + + func removeRecipient(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + Task { + let result = await NextcloudKit.shared.removeUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, instance: recipient.instance, account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } + + func setProperty(share: NKUnifiedShare, propertyClass: String, value: String?) { + Task { + let result = await NextcloudKit.shared.setUnifiedShareProperty(id: share.id, propertyClass: propertyClass, value: value, account: account) + guard let share = result.share else { + propertyErrors[propertyClass] = result.error.errorDescription + return + } + + propertyErrors[propertyClass] = nil + state = .shareUpdated(share: share) + } + } + + /// Activate the draft (draft → active). This is what persists the share and, for invited + /// recipients, triggers the server-side notification/email. + func activate(share: NKUnifiedShare) { + Task { + let result = await NextcloudKit.shared.setUnifiedShareState(id: share.id, state: .active, account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + didActivate = true + state = .shareUpdated(share: share) + NotificationCenter.default.post(name: .unifiedShareDidChange, object: nil) + } + } + + /// Discard on dismiss only while still a draft (an activated/link share is kept). + func discardDraftIfNeeded(share: NKUnifiedShare) { + guard share.state == .draft else { + return + } + + deleteShare(share: share) + } + + func updateRecipientSecret(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient, secret: String) { + Task { + let result = await NextcloudKit.shared.setUnifiedShareRecipientSecret(id: share.id, recipientClass: recipient.class, value: recipient.value, secret: secret, instance: recipient.instance, account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } + + /// Mint a fresh server secret and apply it to the recipient (the "regenerate link" action). + func regenerateRecipientSecret(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + Task { + let generated = await NextcloudKit.shared.generateUnifiedShareSecret(account: account) + guard let secret = generated.secret else { + state = .error(generated.error) + return + } + + updateRecipientSecret(share: share, recipient: recipient, secret: secret) + } + } +} + diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift new file mode 100644 index 00000000..5d804221 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift @@ -0,0 +1,810 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit +#if canImport(UIKit) +import UIKit +#endif + +/// View used for Unified Sharing. +public struct UnifiedShareEditView: View { + let fileName: String + let account: String + /// Editing an existing share (vs composing a new draft): the audience is fixed. + let isEditingExisting: Bool + @State private var model: UnifiedShareEditModel + + @State private var shareeType: ShareeType = .invited + @State private var permissionSelection: PermissionSelection = .unset + @State private var isSettingsExpanded = true + @State private var recipients = "" + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + public init(fileName: String, account: String, sourceId: String? = nil) { + self.fileName = fileName + self.account = account + self.isEditingExisting = false + model = UnifiedShareEditModel(account: account, sourceId: sourceId) + } + + /// Open the editor on an existing share (from the list). + public init(fileName: String, account: String, share: NKUnifiedShare) { + self.fileName = fileName + self.account = account + self.isEditingExisting = true + model = UnifiedShareEditModel(account: account, existingShare: share) + _shareeType = State(initialValue: share.recipients.contains { $0.class == UnifiedShareEditModel.tokenRecipientClass } ? .anyone : .invited) + } + + init(fileName: String, model: UnifiedShareEditModel) { + self.fileName = fileName + self.account = model.account + self.isEditingExisting = false + self.model = model + } + + public var body: some View { + ZStack { + switch model.state { + case .loading: + ProgressView() + case .shareUpdated(let share): + + Form { + Section { + // The audience is only selectable for a new draft; an existing share's is fixed. + if !isEditingExisting { + shareeTypePicker(share: share) + } + + if shareeType == .invited { + if !peopleRecipients(share).isEmpty { + recipientPills(share: share) + .listRowSeparator(.hidden) + } + + 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 { + recipientPills(share: share) + } + + permissionField(share: share) + + ForEach(basicProperties(share), id: \.class) { property in + PropertyRow(property: property, error: model.propertyErrors[property.class]) { value in + model.setProperty(share: share, propertyClass: property.class, value: value) + } + } + } + settingsRow(share: share) + + customLinkSection(share: share) + + actionButtons(share: share) + } + .onDisappear { + model.discardDraftIfNeeded(share: share) + } + .onChange(of: model.didActivate) { + if model.didActivate { + dismiss() + } + } + .navigationTitle(isEditingExisting ? String(localized: "Edit share") : String(localized: "Create a new share")) + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } + // 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 { + if case .loading = model.state { +// model.createShare() + } + + if model.permissionPresets.isEmpty { +// model.loadCapabilities() + } + } + + Spacer() +} + + private func shareeTypePicker(share: NKUnifiedShare) -> some View { + Picker("", selection: $shareeType) { + Text(String(localized: "Invited People")) + .tag(ShareeType.invited) + + Text(String(localized: "Anyone")) + .tag(ShareeType.anyone) + } + .pickerStyle(.segmented) + .listRowSeparator(.hidden) + .onChange(of: shareeType) { + model.setShareeType(share: share, anyone: shareeType == .anyone) + } + } + + @ViewBuilder + private func permissionField(share: NKUnifiedShare) -> some View { + Picker(String(localized: "Participants"), selection: Binding( + get: { isCustomSelected(share) ? Self.customTag : (selectedPresetClass(share) ?? Self.customTag) }, + set: { tag in + if tag == Self.customTag { + permissionSelection = .custom + } else { + permissionSelection = .preset(tag) + model.setPermissionPreset(share: share, presetClass: tag) + } + } + )) { + ForEach(applicablePresets(share), id: \.class) { preset in + Text(preset.displayName) + .tag(preset.class) + } + + // Custom: reveals the per-permission toggles below. Client-side only, no request. + Text(String(localized: "Can…")) + .tag(Self.customTag) + } + .pickerStyle(.menu) + + if isCustomSelected(share) { + ForEach(share.permissions, id: \.class) { permission in + PermissionToggleRow(permission: permission) { enabled in + model.setPermission(share: share, permissionClass: permission.class, enabled: enabled) + } + // Re-seed the toggle whenever the server's enabled value changes (e.g. after a + // preset like "Can edit" recomputes the permissions), not just on first render. + .id(permission.enabled) + } + } + } + + private static let customTag = "__nk_custom_permissions__" + + /// Presets from the capability, narrowed to those the share's permissions reference. + private func applicablePresets(_ share: NKUnifiedShare) -> [NKUnifiedSharePermissionPreset] { + let applicable = Set(share.permissions.flatMap { $0.presets }) + return model.permissionPresets.filter { applicable.contains($0.class) } + } + + /// The effective preset class: the user's pick, else the share's server-side preset. + private func selectedPresetClass(_ share: NKUnifiedShare) -> String? { + switch permissionSelection { + case .unset: return share.permissionPreset + case .custom: return nil + case .preset(let presetClass): return presetClass + } + } + + /// Custom mode (toggles shown) when there's no preset, or the preset isn't a known one. + private func isCustomSelected(_ share: NKUnifiedShare) -> Bool { + guard let presetClass = selectedPresetClass(share) else { + return true + } + + return !applicablePresets(share).contains { $0.class == presetClass } + } + + /// Advanced properties + editable link tokens live behind the disclosure; basic properties inline. + private func settingsRow(share: NKUnifiedShare) -> some View { + DisclosureGroup(isExpanded: $isSettingsExpanded) { + ForEach(advancedProperties(share), id: \.class) { property in + PropertyRow(property: property, error: model.propertyErrors[property.class]) { value in + model.setProperty(share: share, propertyClass: property.class, value: value) + } + } + } label: { + Text(String(localized: "Settings")) + } + } + + @ViewBuilder + private func customLinkSection(share: NKUnifiedShare) -> some View { + if !customLinkRecipients(share).isEmpty { + Section { + ForEach(customLinkRecipients(share), id: \.value) { recipient in + CustomLinkRow( + recipient: recipient, + onCommit: { token in model.updateRecipientSecret(share: share, recipient: recipient, secret: token) }, + onRegenerate: { model.regenerateRecipientSecret(share: share, recipient: recipient) } + ) + } + } footer: { + Text(String(localized: "The link can be changed to be easy to remember, but do not set it to something that is easy to guess.")) + } + } + } + + // Properties are ordered by the server-provided `priority` (ascending), matching Android. + private func basicProperties(_ share: NKUnifiedShare) -> [NKUnifiedShareProperty] { + share.properties.filter { !$0.advanced }.sorted { $0.priority < $1.priority } + } + + private func advancedProperties(_ share: NKUnifiedShare) -> [NKUnifiedShareProperty] { + share.properties.filter { $0.advanced }.sorted { $0.priority < $1.priority } + } + + /// Recipients whose secret can be edited — i.e. custom/private links. + private func customLinkRecipients(_ share: NKUnifiedShare) -> [NKUnifiedShareRecipient] { + share.recipients.filter { $0.secret.updatable } + } + + 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) + recipients = "" + } 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) + } + + @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 recipientPills(share: NKUnifiedShare) -> some View { + FlowLayout(spacing: 8) { + ForEach(peopleRecipients(share), id: \.value) { recipient in + recipientPill(recipient, share: share) + } + } + } + + private func peopleRecipients(_ share: NKUnifiedShare) -> [NKUnifiedShareRecipient] { + share.recipients.filter { !$0.secret.updatable } + } + + private func recipientPill(_ recipient: NKUnifiedShareRecipient, share: NKUnifiedShare) -> some View { + HStack(spacing: 6) { + recipientAvatar(recipient) + + Text(recipient.displayName) + .lineLimit(1) + + Button { + model.removeRecipient(share: share, recipient: recipient) + } label: { + Image(systemName: "xmark") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.quaternary)) + .overlay(Capsule().stroke(.tertiary, lineWidth: 0.5)) + } + + /// The recipient's URL avatar when available, otherwise a circle with its initial. + @ViewBuilder + private func recipientAvatar(_ recipient: NKUnifiedShareRecipient) -> some View { + if let icon = recipient.icon, icon.light != nil || icon.dark != nil { + recipientIcon(icon) + } else { + Circle() + .fill(.quaternary) + .frame(width: 24, height: 24) + .overlay { + Text(recipient.displayName.prefix(1).uppercased()) + .font(.caption) + } + } + } + + private func actionButtons(share: NKUnifiedShare) -> some View { + HStack(spacing: 16) { + Button(String(localized: "Copy public link")) { + Task { + if let link = await model.prepareLinkForCopy(share: share) { + copyToPasteboard(link) + } + } + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + + Button(sendLabel) { + model.activate(share: share) + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(!canSend(share)) + } + .padding(.top, 18) + } + + private var sendLabel: String { + shareeType == .anyone ? String(localized: "Share public link") : String(localized: "Send") + } + + /// Mirrors Android's Share.canSend: a source, a recipient, an enabled permission, no missing + /// required property, and no pending property error. + private func canSend(_ share: NKUnifiedShare) -> Bool { + !share.sources.isEmpty + && !share.recipients.isEmpty + && share.permissions.contains { $0.enabled } + && !share.properties.contains { $0.required && ($0.value ?? "").isEmpty } + && model.propertyErrors.isEmpty + } + + private func copyToPasteboard(_ string: String) { + #if canImport(UIKit) + UIPasteboard.general.string = string + #endif + } +} + +/// A simple left-to-right layout that wraps its subviews onto new rows when they overflow. +private struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var rowWidth: CGFloat = 0 + var rowHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if rowWidth > 0, rowWidth + spacing + size.width > maxWidth { + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + spacing + rowWidth = size.width + rowHeight = size.height + } else { + rowWidth += (rowWidth > 0 ? spacing : 0) + size.width + rowHeight = max(rowHeight, size.height) + } + } + + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + return CGSize(width: min(totalWidth, maxWidth), height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + + subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + +/// 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 PermissionSelection: Equatable { + case unset + case custom + case preset(String) + } +} + +private struct PermissionToggleRow: View { + let permission: NKUnifiedSharePermission + let onChange: (Bool) -> Void + + @State private var isOn: Bool + + init(permission: NKUnifiedSharePermission, onChange: @escaping (Bool) -> Void) { + self.permission = permission + self.onChange = onChange + _isOn = State(initialValue: permission.enabled) + } + + var body: some View { + Toggle(permission.displayName, isOn: $isOn) + .onChange(of: isOn) { + onChange(isOn) + } + } +} + +/// Renders the right editor for a property's concrete type, with a hint/error caption. +private struct PropertyRow: View { + let property: NKUnifiedShareProperty + let error: String? + let onCommit: (String?) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + editor + + if let caption { + Text(caption) + .font(.caption) + .foregroundStyle(error != nil ? Color.red : Color.secondary) + } + } + } + + private var caption: String? { + if let error, !error.isEmpty { + return error + } + + if let hint = property.hint, !hint.isEmpty { + return hint + } + + return nil + } + + @ViewBuilder + private var editor: some View { + switch property { + case let property as NKUnifiedSharePropertyBoolean: + BooleanPropertyEditor(property: property, onCommit: onCommit) + case let property as NKUnifiedSharePropertyEnum: + EnumPropertyEditor(property: property, onCommit: onCommit) + case let property as NKUnifiedSharePropertyDate: + DatePropertyEditor(property: property, onCommit: onCommit) + case let property as NKUnifiedSharePropertyPassword: + TextPropertyEditor(property: property, secure: true, onCommit: onCommit) + case let property as NKUnifiedSharePropertyString: + TextPropertyEditor(property: property, secure: false, onCommit: onCommit) + default: + LabeledContent(property.displayName) { + Text(property.value ?? "").foregroundStyle(.secondary) + } + } + } +} + +private struct BooleanPropertyEditor: View { + let property: NKUnifiedSharePropertyBoolean + let onCommit: (String?) -> Void + @State private var isOn: Bool + + init(property: NKUnifiedSharePropertyBoolean, onCommit: @escaping (String?) -> Void) { + self.property = property + self.onCommit = onCommit + _isOn = State(initialValue: property.value == "true") + } + + var body: some View { + Toggle(property.displayName, isOn: $isOn) + .onChange(of: isOn) { + onCommit(isOn ? "true" : "false") + } + } +} + +private struct EnumPropertyEditor: View { + let property: NKUnifiedSharePropertyEnum + let onCommit: (String?) -> Void + @State private var selection: String + + init(property: NKUnifiedSharePropertyEnum, onCommit: @escaping (String?) -> Void) { + self.property = property + self.onCommit = onCommit + _selection = State(initialValue: property.value ?? property.validValues.first ?? "") + } + + var body: some View { + Picker(property.displayName, selection: $selection) { + ForEach(property.validValues, id: \.self) { value in + Text(value).tag(value) + } + } + .pickerStyle(.menu) + .onChange(of: selection) { + onCommit(selection) + } + } +} + +private struct DatePropertyEditor: View { + let property: NKUnifiedSharePropertyDate + let onCommit: (String?) -> Void + @State private var date: Date + @State private var hasDate: Bool + + init(property: NKUnifiedSharePropertyDate, onCommit: @escaping (String?) -> Void) { + self.property = property + self.onCommit = onCommit + let parsed = Self.parse(property.value) + _date = State(initialValue: parsed ?? Date()) + _hasDate = State(initialValue: parsed != nil) + } + + var body: some View { + if hasDate { + HStack(spacing: 12) { + DatePicker(property.displayName, selection: $date, in: lowerBound, displayedComponents: .date) + .onChange(of: date) { + onCommit(Self.format(date)) + } + + Button { + hasDate = false + onCommit(nil) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(.leading, 4) + } + } else { + Button { + hasDate = true + onCommit(Self.format(date)) + } label: { + HStack { + Text(property.displayName) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "calendar.badge.plus") + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + private var lowerBound: PartialRangeFrom { + (Self.parse(property.minDate) ?? Date())... + } + + private static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withColonSeparatorInTimeZone] + formatter.timeZone = .current + return formatter + }() + + static func parse(_ string: String?) -> Date? { + guard let string, !string.isEmpty else { + return nil + } + + return formatter.date(from: string) ?? ISO8601DateFormatter().date(from: string) + } + + static func format(_ date: Date) -> String { + formatter.string(from: Calendar.current.startOfDay(for: date)) + } +} + +private struct TextPropertyEditor: View { + let property: NKUnifiedShareProperty + let secure: Bool + let onCommit: (String?) -> Void + @State private var text: String + @State private var committed: String + @FocusState private var focused: Bool + + init(property: NKUnifiedShareProperty, secure: Bool, onCommit: @escaping (String?) -> Void) { + self.property = property + self.secure = secure + self.onCommit = onCommit + let initial = property.value ?? "" + _text = State(initialValue: initial) + _committed = State(initialValue: initial) + } + + var body: some View { + field + .focused($focused) + .onChange(of: focused) { + if !focused { + commit() + } + } + .onSubmit { + commit() + } + } + + @ViewBuilder + private var field: some View { + if secure { + SecureField(property.displayName, text: $text) + } else { + TextField(property.displayName, text: $text) + } + } + + private func commit() { + guard text != committed else { + return + } + + committed = text + onCommit(text) + } +} + +private struct CustomLinkRow: View { + let recipient: NKUnifiedShareRecipient + let onCommit: (String) -> Void + let onRegenerate: () -> Void + private let prefix: String + @State private var token: String + @State private var committed: String + @FocusState private var focused: Bool + + private static let maxTokenLength = 32 + + init(recipient: NKUnifiedShareRecipient, onCommit: @escaping (String) -> Void, onRegenerate: @escaping () -> Void) { + self.recipient = recipient + self.onCommit = onCommit + self.onRegenerate = onRegenerate + let initial = recipient.secret.value ?? "" + _token = State(initialValue: initial) + _committed = State(initialValue: initial) + + // The prefix is the link URL with the token suffix stripped (e.g. ".../index.php/s/"). + let url = recipient.secret.url ?? "" + if !initial.isEmpty, url.hasSuffix(initial) { + self.prefix = String(url.dropLast(initial.count)) + } else { + self.prefix = url + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Custom link")) + .font(.headline) + + if !prefix.isEmpty { + Text(prefix) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + TextField(String(localized: "Link token"), text: $token) + .focused($focused) + .onChange(of: token) { + if token.count > Self.maxTokenLength { + token = String(token.prefix(Self.maxTokenLength)) + } + } + .onChange(of: focused) { + if !focused, token != committed, !token.isEmpty { + committed = token + onCommit(token) + } + } + + Button { + onRegenerate() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Refresh link")) + } + } + .padding(.vertical, 4) + } +} + +#Preview { + UnifiedShareEditView( + fileName: "Test.txt", + model: UnifiedShareEditModel( + account: "", + state: .shareUpdated(share: .mock), + recipientResults: .mocks, + permissionPresets: [ + NKUnifiedSharePermissionPreset(class: "viewer", displayName: "Can view"), + NKUnifiedSharePermissionPreset(class: "editor", displayName: "Can edit") + ] + ) + ) +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListModel.swift new file mode 100644 index 00000000..bd809d40 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListModel.swift @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +extension Notification.Name { + /// Posted when a share is created/activated elsewhere, so any visible list can refresh. + static let unifiedShareDidChange = Notification.Name("unifiedShareDidChange") +} + +enum UnifiedShareListState { + case loading + case loaded([NKUnifiedShare]) + case error(Error) +} + +@MainActor +@Observable +public class UnifiedShareListModel { + var state: UnifiedShareListState = .loading + /// Permission presets (for the per-row permission chip label). + var permissionPresets: [NKUnifiedSharePermissionPreset] = [] + let account: String + /// The file/folder whose shares are listed (nil lists everything). + let sourceId: String? + + init(account: String, sourceId: String?) { + self.account = account + self.sourceId = sourceId + } + + func load() { + Task { + if permissionPresets.isEmpty { + let capabilities = await NextcloudKit.shared.getUnifiedSharingCapabilities(account: account) + permissionPresets = capabilities.capabilities?.permissionPresets ?? [] + } + + await refresh() + } + } + + func refresh() async { + let result = await NextcloudKit.shared.listUnifiedShares( + filterSourceTypeClass: UnifiedShareEditModel.nodeSourceClass, + filterSourceTypeValue: sourceId, + account: account + ) + + guard let shares = result.shares else { + state = .error(result.error) + return + } + + // Only active shares are shown; drafts are in-progress and deleted ones are gone. + state = .loaded(shares.filter { $0.state == .active }) + } + + /// Presets applicable to a share (those its permissions reference), for the quick chip. + func applicablePresets(_ share: NKUnifiedShare) -> [NKUnifiedSharePermissionPreset] { + let applicable = Set(share.permissions.flatMap { $0.presets }) + return permissionPresets.filter { applicable.contains($0.class) } + } + + func setPermissionPreset(share: NKUnifiedShare, presetClass: String) { + Task { + let result = await NextcloudKit.shared.setUnifiedSharePermissionPreset(id: share.id, permissionPresetClass: presetClass, account: account) + guard let updated = result.share else { + return + } + + replace(updated) + } + } + + private func replace(_ updated: NKUnifiedShare) { + if case .loaded(let shares) = state { + state = .loaded(shares.map { $0.id == updated.id ? updated : $0 }) + } + } + + func delete(share: NKUnifiedShare) { + if case .loaded(let shares) = state { + state = .loaded(shares.filter { $0.id != share.id }) + } + + Task { + await NextcloudKit.shared.deleteUnifiedShare(id: share.id, account: account) + } + } +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListView.swift new file mode 100644 index 00000000..6ceeea1a --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListView.swift @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +/// Lists the existing unified shares for a file, with tap-to-edit and swipe-to-delete. +public struct UnifiedShareListView: View { + let fileName: String + let account: String + /// Brand/accent color (the app passes NCBrandColor); used for the chip and ⋯ button. + let tint: Color + @State private var model: UnifiedShareListModel + @State private var editingShare: NKUnifiedShare? + @State private var shareToDelete: NKUnifiedShare? + @Environment(\.colorScheme) private var colorScheme + + public init(fileName: String, account: String, sourceId: String? = nil, tint: Color = .accentColor) { + self.fileName = fileName + self.account = account + self.tint = tint + model = UnifiedShareListModel(account: account, sourceId: sourceId) + } + + public var body: some View { + content + .task { + if case .loading = model.state { + model.load() + } + } + .sheet(item: $editingShare, onDismiss: { + Task { await model.refresh() } + }) { share in + NavigationStack { + UnifiedShareEditView(fileName: fileName, account: account, share: share) + } + } + // A share created/activated from the "+" modal (outside this view) refreshes the list. + .onReceive(NotificationCenter.default.publisher(for: .unifiedShareDidChange)) { _ in + Task { await model.refresh() } + } + .confirmationDialog( + String(localized: "Delete share?"), + isPresented: Binding(get: { shareToDelete != nil }, set: { if !$0 { shareToDelete = nil } }), + titleVisibility: .visible, + presenting: shareToDelete + ) { share in + Button(String(localized: "Delete"), role: .destructive) { + model.delete(share: share) + shareToDelete = nil + } + + Button(String(localized: "Cancel"), role: .cancel) { + shareToDelete = nil + } + } message: { _ in + Text(String(localized: "This share will be permanently removed.")) + } + } + + @ViewBuilder + private var content: some View { + switch model.state { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .error(let error): + ContentUnavailableView { + Label(String(localized: "Couldn't load shares"), systemImage: "exclamationmark.triangle") + } description: { + Text(error.localizedDescription) + } actions: { + Button(String(localized: "Retry")) { + model.load() + } + } + + case .loaded(let shares): + List { + ForEach(shares) { share in + shareRow(share, allShares: shares) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + shareToDelete = share + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + .tint(.red) + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await model.refresh() + } + .overlay { + if shares.isEmpty { + ContentUnavailableView( + String(localized: "No shares yet"), + systemImage: "person.2.slash", + description: Text(String(localized: "Use the + button to share \(fileName).")) + ) + } + } + } + } + + // Row body isn't tappable (matching Android): use the chip for permissions, the ⋯ menu to edit. + private func shareRow(_ share: NKUnifiedShare, allShares: [NKUnifiedShare]) -> some View { + HStack(spacing: 12) { + shareIcon(share) + .frame(width: 32, height: 32) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(headline(share, in: allShares)) + .foregroundStyle(.primary) + + presetChip(share) + } + + Spacer() + + overflowMenu(share) + } + } + + // MARK: - Row content + + @ViewBuilder + private func shareIcon(_ share: NKUnifiedShare) -> some View { + if let icon = share.recipients.first?.icon, + let urlString = iconURL(icon), + let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + letterAvatar(share) + } + } else { + letterAvatar(share) + } + } + + /// Colored circle with the recipient's initial — the native stand-in for the server avatar. + private func letterAvatar(_ share: NKUnifiedShare) -> some View { + let name = share.recipients.first?.displayName ?? "" + let initial = name.first.map { String($0).uppercased() } ?? "?" + + return Circle() + .fill(avatarColor(for: name.isEmpty ? initial : name)) + .overlay { + Text(initial) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + + private func avatarColor(for string: String) -> Color { + let hash = string.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } + return Color(hue: Double(hash % 360) / 360.0, saturation: 0.55, brightness: 0.55) + } + + private func headline(_ share: NKUnifiedShare, in shares: [NKUnifiedShare]) -> String { + guard isLink(share) else { + return peopleRecipients(share).first?.displayName ?? String(localized: "Share") + } + + if let label = label(share) { + return label + } + + let links = shares.filter { isLink($0) } + if links.count <= 1 { + return String(localized: "Share link") + } + + let position = (links.sorted { $0.lastUpdated < $1.lastUpdated }.firstIndex { $0.id == share.id } ?? 0) + 1 + return String(localized: "Share link (\(position))") + } + + /// The preset chip — a menu of the applicable presets plus "Custom permissions" (opens the editor). + private func presetChip(_ share: NKUnifiedShare) -> some View { + Menu { + ForEach(model.applicablePresets(share), id: \.class) { preset in + Button(preset.displayName) { + model.setPermissionPreset(share: share, presetClass: preset.class) + } + } + + Divider() + + Button(String(localized: "Can…")) { + editingShare = share + } + } label: { + HStack(spacing: 2) { + Text(presetLabel(share)) + Image(systemName: "chevron.down") + .font(.caption2) + } + .font(.subheadline) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .background(tint.opacity(0.12), in: Capsule()) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + private func overflowMenu(_ share: NKUnifiedShare) -> some View { + Menu { + Button { + editingShare = share + } label: { + Label(String(localized: "Edit"), systemImage: "pencil") + } + + Button(role: .destructive) { + shareToDelete = share + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .foregroundStyle(tint) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + } + + private func presetLabel(_ share: NKUnifiedShare) -> String { + if let presetClass = share.permissionPreset, + let preset = model.permissionPresets.first(where: { $0.class == presetClass }) { + return preset.displayName + } + + return String(localized: "Can…") + } + + // MARK: - Helpers + + /// Matches Android's `belongsAnyoneTab`: the share carries an editable-secret (link) recipient. + private func isLink(_ share: NKUnifiedShare) -> Bool { + share.recipients.contains { $0.secret.updatable } + } + + private func peopleRecipients(_ share: NKUnifiedShare) -> [NKUnifiedShareRecipient] { + share.recipients.filter { !$0.secret.updatable } + } + + /// A property whose class contains "label" carries the link's custom label. + private func label(_ share: NKUnifiedShare) -> String? { + let value = share.properties.first { $0.class.range(of: "label", options: .caseInsensitive) != nil }?.value + guard let value, !value.isEmpty else { + return nil + } + + return value + } + + private func iconURL(_ icon: NKUnifiedShareIcon) -> String? { + (colorScheme == .dark ? icon.dark : icon.light) ?? icon.light ?? icon.dark + } +} 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..ac4c3f9a --- /dev/null +++ b/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift @@ -0,0 +1,264 @@ +// 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, + "advanced": false, + "value": null, + "type": "date", + "min_date": "2026-01-01", + "max_date": null + }, + { + "class": "role", + "display_name": "Role", + "hint": null, + "priority": 20, + "required": true, + "advanced": true, + "value": "editor", + "type": "enum", + "valid_values": ["viewer", "editor"] + }, + { + "class": "download", + "display_name": "Allow download", + "hint": null, + "priority": 30, + "required": false, + "advanced": false, + "value": "true", + "type": "boolean" + }, + { + "class": "password", + "display_name": "Password", + "hint": "Min 8 chars", + "priority": 40, + "required": false, + "advanced": true, + "value": null, + "type": "password" + }, + { + "class": "note", + "display_name": "Note", + "hint": null, + "priority": 50, + "required": false, + "advanced": 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.advanced == false) + #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.advanced == true) + #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) + } + + @Test("Decodes permission, recipient secret/initiator and share permission_preset") + func decodesPermissionRecipientAndPreset() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s3", + "owner": { "user_id": "alice", "instance": null, "display_name": "Alice", "icon": { "svg": "" } }, + "last_updated": 0, + "state": "active", + "sources": [], + "recipients": [ + { + "class": "link", + "value": "token", + "instance": null, + "display_name": "Public link", + "icon": null, + "secret": { "updatable": true, "url": "https://x/s/abc" }, + "initiator": { "user_id": "alice", "instance": null, "display_name": "Alice", "icon": { "svg": "" } } + } + ], + "permissions": [ + { + "class": "download", + "source_class": null, + "display_name": "Allow download", + "hint": null, + "priority": 30, + "presets": ["viewer", "editor"], + "enabled": true + } + ], + "properties": [], + "permission_preset": "editor" + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.permissionPreset == "editor") + + let permission = try #require(share.permissions.first) + #expect(permission.sourceClass == nil) + #expect(permission.priority == 30) + #expect(permission.presets == ["viewer", "editor"]) + #expect(permission.enabled == true) + + let recipient = try #require(share.recipients.first) + #expect(recipient.secret.updatable == true) + #expect(recipient.secret.url == "https://x/s/abc") + #expect(recipient.secret.value == nil) + #expect(recipient.initiator?.userId == "alice") + } + + @Test("Decodes the sharing capabilities block") + func decodesSharingCapabilities() throws { + let json = """ + { + "api_versions": ["v1"], + "source_types": [ { "class": "file" } ], + "permission_presets": [ { "class": "viewer", "display_name": "Viewer", "hint": null } ] + } + """ + let caps = try JSONDecoder().decode(NKUnifiedSharingCapabilities.self, from: Data(json.utf8)) + + #expect(caps.apiVersions == ["v1"]) + #expect(caps.sourceTypes.first?.class == "file") + #expect(caps.permissionPresets.first?.class == "viewer") + #expect(caps.permissionPresets.first?.displayName == "Viewer") + } +}