import CoreTransferable import SwiftUI import UniformTypeIdentifiers struct WorkbookExport: Transferable { let url: URL let filename: String init(url: URL, title: String) { self.url = url filename = Self.filename(for: title) } static func filename(for title: String) -> String { var name = title.components(separatedBy: .controlCharacters).joined() .replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") .trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: "."))) while name.utf8.count > 200 { name.removeLast() } if name.isEmpty { name = "Workbook" } return name.lowercased().hasSuffix(".xlsx") ? name : name + ".xlsx" } static var transferRepresentation: some TransferRepresentation { FileRepresentation(exportedContentType: UTType(filenameExtension: "xlsx") ?? .data) { workbook in SentTransferredFile(workbook.url, allowAccessingOriginalFile: false) }.suggestedFileName { $0.filename } } } struct ImportStarted: Decodable { let job: String } struct ImportProgress: Decodable { let status: String let progress: String let error: String let location: String } struct SheetFiles: View { @Bindable var model: SheetModel let importing: Bool var openImported: ((UInt64, String) -> Void)? = nil @State private var picker = false @State private var exporter = false @State private var workbook: WorkbookExport? @State private var active = true @State private var progress = "" @State private var working = false var body: some View { Group { if importing { Text("Import an .xlsx or .xlsm workbook up to 20 MB. Only its first worksheet is imported.") Button("Choose workbook") { picker = true }.disabled(working) } else { Button("Save workbook…") { Task { await export() } }.disabled( working || model.hasUnconfirmedChanges || model.busy) } if working { ProgressView() } if !progress.isEmpty { Text(progress) } }.fileImporter( isPresented: $picker, allowedContentTypes: [ UTType(filenameExtension: "xlsx") ?? .data, UTType(filenameExtension: "xlsm") ?? .data, ] ) { result in switch result { case .success(let url): Task { await upload(url) } case .failure: progress = "Could not open that workbook." } } .fileExporter( isPresented: $exporter, item: workbook, contentTypes: [UTType(filenameExtension: "xlsx") ?? .data], defaultFilename: model.page?.Sheet.Title ?? "Workbook", onCompletion: { result in if case .failure = result { progress = "The workbook could not be saved." } cleanupExport() }, onCancellation: { cleanupExport() } ) .onAppear { active = true } .onDisappear { active = false if !exporter { cleanupExport() } } } private func cleanupExport() { if let workbook { try? FileManager.default.removeItem(at: workbook.url.deletingLastPathComponent()) } workbook = nil } private func export() async { guard !model.hasUnconfirmedChanges && !model.busy else { progress = "Save your cell edit first." return } working = true defer { working = false } let directory = FileManager.default.temporaryDirectory .appendingPathComponent("oficina-sheets-export-" + UUID().uuidString, isDirectory: true) let title = model.page?.Sheet.Title ?? "Workbook" let destination = directory.appendingPathComponent(WorkbookExport.filename(for: title)) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) try await model.client.download(model.base + "/export.xlsx", to: destination) guard active else { try? FileManager.default.removeItem(at: directory) return } workbook = WorkbookExport(url: destination, title: title) exporter = true } catch { try? FileManager.default.removeItem(at: directory) progress = error.localizedDescription } } private func upload(_ url: URL) async { working = true defer { working = false } let access = url.startAccessingSecurityScopedResource() defer { if access { url.stopAccessingSecurityScopedResource() } } do { let file = try Data(contentsOf: url, options: .mappedIfSafe) guard file.count <= 20 << 20 else { progress = "The workbook exceeds 20 MB." return } let ext = url.pathExtension.lowercased() guard ext == "xlsx" || ext == "xlsm" else { progress = "Choose an .xlsx or .xlsm workbook." return } let boundary = UUID().uuidString let filename = url.lastPathComponent .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "\r", with: "") .replacingOccurrences(of: "\n", with: "") var body = Data( "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: application/octet-stream\r\n\r\n" .utf8) body.append(file) body.append(Data("\r\n--\(boundary)--\r\n".utf8)) let started: ImportStarted = try await model.client.request( "/sheets/import", method: "POST", body: body, contentType: "multipart/form-data; boundary=\(boundary)") while !Task.isCancelled { let state: ImportProgress = try await model.client.request( "/api/native/jobs/\(started.job)") progress = state.progress if state.status == "done" { progress = "Imported" await model.loadList() if let id = UInt64(state.location.split(separator: "/").last ?? ""), let sheet = model.sheets.first(where: { $0.ID == id }) { if let openImported { openImported(sheet.ID, sheet.Title) } else { await model.open(sheet) } } return } if state.status == "failed" { progress = state.error return } try await Task.sleep(for: .seconds(1)) } } catch { progress = error.localizedDescription } } }