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