RadrootsDocumentPresentation.swift (14663B)
1 import Foundation 2 import SwiftUI 3 import UniformTypeIdentifiers 4 import CoreTransferable 5 6 public enum RadrootsDocumentPresentationAdapter { 7 public static func contentTypes(for request: RadrootsDocumentImportRequest) -> [UTType] { 8 let types = request.allowedContentKinds.map(contentType(for:)) 9 return types.isEmpty ? [.item] : types 10 } 11 12 public static func contentType(for kind: RadrootsDocumentContentKind) -> UTType { 13 switch kind { 14 case .json: 15 .json 16 case .plainText: 17 .plainText 18 case .url: 19 .url 20 case .file: 21 .item 22 case .stagedBlob: 23 .data 24 } 25 } 26 27 public static func contentType(forMediaType mediaType: String?) -> UTType { 28 guard let mediaType, let contentType = UTType(mimeType: mediaType) else { 29 return .data 30 } 31 return contentType 32 } 33 34 public static func importDestination( 35 sourceURL: URL, 36 scope: RadrootsFileScope, 37 importID: String 38 ) throws -> RadrootsFileReference { 39 let filename = sourceURL.lastPathComponent.isEmpty ? "document" : sourceURL.lastPathComponent 40 let normalizedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(filename) 41 let normalizedImportID = try RadrootsPreparedExportDocument.normalizedPreparedID(importID) 42 return RadrootsFileReference( 43 scope: scope, 44 relativePath: "document_import/\(normalizedImportID)/\(normalizedFilename)" 45 ) 46 } 47 48 public static func transferItem(for request: RadrootsShareRequest) throws -> RadrootsShareTransferItem { 49 try transferItem(for: request, fileAccess: nil) 50 } 51 52 public static func transferItem( 53 for request: RadrootsShareRequest, 54 fileAccess: any RadrootsFileAccess 55 ) throws -> RadrootsShareTransferItem { 56 let optionalFileAccess: (any RadrootsFileAccess)? = fileAccess 57 return try transferItem(for: request, fileAccess: optionalFileAccess) 58 } 59 60 private static func transferItem( 61 for request: RadrootsShareRequest, 62 fileAccess: (any RadrootsFileAccess)? 63 ) throws -> RadrootsShareTransferItem { 64 for item in request.items { 65 switch try item.normalized { 66 case .text(let text): 67 return try RadrootsShareTransferItem(text: text, subject: request.subject) 68 case .url(let url): 69 return try RadrootsShareTransferItem(url: url, subject: request.subject) 70 case .file(let file, let suggestedFilename, let mediaType, let sizeBytes): 71 guard let fileAccess else { 72 continue 73 } 74 let export = try fileAccess.prepareExport( 75 RadrootsExportDocumentRequest( 76 source: .file(file), 77 suggestedFilename: try shareFilename( 78 explicitFilename: suggestedFilename, 79 fallbackFilename: NSString(string: file.relativePath).lastPathComponent 80 ), 81 mediaType: mediaType, 82 sizeBytes: sizeBytes 83 ) 84 ) 85 return try RadrootsShareTransferItem(preparedExport: export, subject: request.subject) 86 case .stagedBlob(let stagedBlob, let suggestedFilename): 87 guard let fileAccess else { 88 continue 89 } 90 let export = try fileAccess.prepareExport( 91 RadrootsExportDocumentRequest( 92 source: .stagedBlob(stagedBlob), 93 suggestedFilename: try shareFilename( 94 explicitFilename: suggestedFilename, 95 fallbackFilename: stagedBlob.filenameHint ?? stagedBlob.blobID 96 ), 97 mediaType: stagedBlob.mediaType, 98 sizeBytes: UInt64(stagedBlob.sizeBytes) 99 ) 100 ) 101 return try RadrootsShareTransferItem(preparedExport: export, subject: request.subject) 102 } 103 } 104 throw RadrootsDocumentInterchangeError.invalidRequest("share request does not contain a supported public share item") 105 } 106 107 private static func shareFilename(explicitFilename: String?, fallbackFilename: String) throws -> String { 108 if let explicitFilename { 109 return try RadrootsDocumentInterchangeValidation.normalizedFilename(explicitFilename) 110 } 111 let fallback = fallbackFilename.trimmingCharacters(in: .whitespacesAndNewlines) 112 if fallback.isEmpty { 113 return "radroots-share-item" 114 } 115 return try RadrootsDocumentInterchangeValidation.normalizedFilename(fallback) 116 } 117 } 118 119 public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hashable { 120 public enum Payload: Sendable, Equatable, Hashable { 121 case text(String) 122 case url(URL) 123 case file(RadrootsPreparedExportDocument) 124 } 125 126 public let payload: Payload 127 public let subject: String? 128 129 public init(text: String, subject: String? = nil) throws { 130 self.payload = .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(text, field: "share transfer text")) 131 self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( 132 subject, 133 field: "share transfer subject" 134 ) 135 } 136 137 public init(url: URL, subject: String? = nil) throws { 138 self.payload = .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(url)) 139 self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( 140 subject, 141 field: "share transfer subject" 142 ) 143 } 144 145 public init(preparedExport: RadrootsPreparedExportDocument, subject: String? = nil) throws { 146 self.payload = .file(preparedExport) 147 self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( 148 subject, 149 field: "share transfer subject" 150 ) 151 } 152 153 public var text: String? { 154 switch payload { 155 case .text(let text): 156 text 157 case .url(let url): 158 url.absoluteString 159 case .file: 160 nil 161 } 162 } 163 164 public var url: URL? { 165 guard case .url(let url) = payload else { 166 return nil 167 } 168 return url 169 } 170 171 public var preparedExport: RadrootsPreparedExportDocument? { 172 guard case .file(let preparedExport) = payload else { 173 return nil 174 } 175 return preparedExport 176 } 177 178 public var transferText: String { 179 switch payload { 180 case .text(let text): 181 text 182 case .url(let url): 183 url.absoluteString 184 case .file(let preparedExport): 185 preparedExport.suggestedFilename 186 } 187 } 188 189 public static var transferRepresentation: some TransferRepresentation { 190 ProxyRepresentation(exporting: \.transferText) 191 } 192 } 193 194 public struct RadrootsPreparedExportFileDocument: FileDocument { 195 public static var readableContentTypes: [UTType] { 196 [.data] 197 } 198 199 public let fileURL: URL 200 201 public init(preparedExport: RadrootsPreparedExportDocument) { 202 self.fileURL = preparedExport.fileURL 203 } 204 205 public init(configuration: ReadConfiguration) throws { 206 throw RadrootsDocumentInterchangeError.invalidRequest("prepared export documents are write only") 207 } 208 209 public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 210 try FileWrapper(url: fileURL, options: []) 211 } 212 } 213 214 public struct RadrootsDocumentImportPresentationModifier: ViewModifier { 215 @Binding private var request: RadrootsDocumentImportRequest? 216 private let fileAccess: any RadrootsFileAccess 217 private let onCompletion: (Result<RadrootsDocumentImportResult, Error>) -> Void 218 219 public init( 220 request: Binding<RadrootsDocumentImportRequest?>, 221 fileAccess: any RadrootsFileAccess, 222 onCompletion: @escaping (Result<RadrootsDocumentImportResult, Error>) -> Void 223 ) { 224 self._request = request 225 self.fileAccess = fileAccess 226 self.onCompletion = onCompletion 227 } 228 229 public func body(content: Content) -> some View { 230 content.fileImporter( 231 isPresented: Binding( 232 get: { request != nil }, 233 set: { isPresented in 234 if !isPresented { 235 request = nil 236 } 237 } 238 ), 239 allowedContentTypes: request.map(RadrootsDocumentPresentationAdapter.contentTypes(for:)) ?? [.item], 240 allowsMultipleSelection: request?.allowsMultipleSelection ?? false 241 ) { result in 242 handleImportResult(result) 243 } 244 } 245 246 private func handleImportResult(_ result: Result<[URL], Error>) { 247 guard let currentRequest = request else { 248 return 249 } 250 request = nil 251 do { 252 let urls = try result.get() 253 guard !urls.isEmpty else { 254 throw RadrootsDocumentInterchangeError.userCancelled("document import was cancelled") 255 } 256 let documents = try urls.map { sourceURL in 257 let destination = try RadrootsDocumentPresentationAdapter.importDestination( 258 sourceURL: sourceURL, 259 scope: currentRequest.destinationScope, 260 importID: UUID().uuidString.lowercased() 261 ) 262 return try fileAccess.copyExternalFile( 263 sourceURL, 264 to: destination, 265 mediaType: nil, 266 suggestedFilename: sourceURL.lastPathComponent 267 ) 268 } 269 onCompletion(.success(try RadrootsDocumentImportResult(documents: documents))) 270 } catch { 271 onCompletion(.failure(error)) 272 } 273 } 274 } 275 276 public struct RadrootsDocumentExportPresentationModifier: ViewModifier { 277 @Binding private var preparedExport: RadrootsPreparedExportDocument? 278 private let onCompletion: (Result<RadrootsExportDocumentResult, Error>) -> Void 279 280 public init( 281 preparedExport: Binding<RadrootsPreparedExportDocument?>, 282 onCompletion: @escaping (Result<RadrootsExportDocumentResult, Error>) -> Void 283 ) { 284 self._preparedExport = preparedExport 285 self.onCompletion = onCompletion 286 } 287 288 public func body(content: Content) -> some View { 289 content.fileExporter( 290 isPresented: Binding( 291 get: { preparedExport != nil }, 292 set: { isPresented in 293 if !isPresented { 294 preparedExport = nil 295 } 296 } 297 ), 298 document: preparedExport.map(RadrootsPreparedExportFileDocument.init(preparedExport:)), 299 contentType: RadrootsDocumentPresentationAdapter.contentType(forMediaType: preparedExport?.mediaType), 300 defaultFilename: preparedExport?.suggestedFilename 301 ) { result in 302 handleExportResult(result) 303 } 304 } 305 306 private func handleExportResult(_ result: Result<URL, Error>) { 307 guard let currentExport = preparedExport else { 308 return 309 } 310 preparedExport = nil 311 do { 312 let destinationURL = try result.get() 313 onCompletion( 314 .success( 315 try RadrootsExportDocumentResult( 316 exportedFilename: destinationURL.lastPathComponent.isEmpty 317 ? currentExport.suggestedFilename 318 : destinationURL.lastPathComponent, 319 mediaType: currentExport.mediaType, 320 sizeBytes: currentExport.sizeBytes 321 ) 322 ) 323 ) 324 } catch { 325 onCompletion(.failure(error)) 326 } 327 } 328 } 329 330 public struct RadrootsSharePresentationLink<Label: View>: View { 331 private let transferItem: RadrootsShareTransferItem 332 private let label: () -> Label 333 334 public init( 335 request: RadrootsShareRequest, 336 @ViewBuilder label: @escaping () -> Label 337 ) throws { 338 self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem(for: request) 339 self.label = label 340 } 341 342 public init( 343 request: RadrootsShareRequest, 344 fileAccess: any RadrootsFileAccess, 345 @ViewBuilder label: @escaping () -> Label 346 ) throws { 347 self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem( 348 for: request, 349 fileAccess: fileAccess 350 ) 351 self.label = label 352 } 353 354 @ViewBuilder 355 public var body: some View { 356 switch transferItem.payload { 357 case .text(let text): 358 ShareLink( 359 item: text, 360 subject: transferItem.subject.map(Text.init) ?? Text(""), 361 message: Text(text), 362 label: label 363 ) 364 case .url(let url): 365 ShareLink( 366 item: url, 367 subject: transferItem.subject.map(Text.init) ?? Text(""), 368 message: Text(url.absoluteString), 369 label: label 370 ) 371 case .file(let preparedExport): 372 ShareLink( 373 item: preparedExport.fileURL, 374 subject: transferItem.subject.map(Text.init) ?? Text(""), 375 message: Text(preparedExport.suggestedFilename), 376 label: label 377 ) 378 } 379 } 380 } 381 382 public extension View { 383 func radrootsDocumentImporter( 384 request: Binding<RadrootsDocumentImportRequest?>, 385 fileAccess: any RadrootsFileAccess, 386 onCompletion: @escaping (Result<RadrootsDocumentImportResult, Error>) -> Void 387 ) -> some View { 388 modifier( 389 RadrootsDocumentImportPresentationModifier( 390 request: request, 391 fileAccess: fileAccess, 392 onCompletion: onCompletion 393 ) 394 ) 395 } 396 397 func radrootsDocumentExporter( 398 preparedExport: Binding<RadrootsPreparedExportDocument?>, 399 onCompletion: @escaping (Result<RadrootsExportDocumentResult, Error>) -> Void 400 ) -> some View { 401 modifier( 402 RadrootsDocumentExportPresentationModifier( 403 preparedExport: preparedExport, 404 onCompletion: onCompletion 405 ) 406 ) 407 } 408 }