| 1 | import CoreTransferable |
| 2 | import SwiftUI |
| 3 | import UniformTypeIdentifiers |
| 4 | |
| 5 | struct WorkbookExport: Transferable { |
| 6 | let url: URL |
| 7 | let filename: String |
| 8 | init(url: URL, title: String) { |
| 9 | self.url = url |
| 10 | filename = Self.filename(for: title) |
| 11 | } |
| 12 | static func filename(for title: String) -> String { |
| 13 | var name = title.components(separatedBy: .controlCharacters).joined() |
| 14 | .replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") |
| 15 | .trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: "."))) |
| 16 | while name.utf8.count > 200 { name.removeLast() } |
| 17 | if name.isEmpty { name = "Workbook" } |
| 18 | return name.lowercased().hasSuffix(".xlsx") ? name : name + ".xlsx" |
| 19 | } |
| 20 | static var transferRepresentation: some TransferRepresentation { |
| 21 | FileRepresentation(exportedContentType: UTType(filenameExtension: "xlsx") ?? .data) { |
| 22 | workbook in |
| 23 | SentTransferredFile(workbook.url, allowAccessingOriginalFile: false) |
| 24 | }.suggestedFileName { $0.filename } |
| 25 | } |
| 26 | } |
| 27 | struct ImportStarted: Decodable { let job: String } |
| 28 | struct ImportProgress: Decodable { |
| 29 | let status: String |
| 30 | let progress: String |
| 31 | let error: String |
| 32 | let location: String |
| 33 | } |
| 34 | struct SheetFiles: View { |
| 35 | @Bindable var model: SheetModel |
| 36 | let importing: Bool |
| 37 | var openImported: ((UInt64, String) -> Void)? = nil |
| 38 | @State private var picker = false |
| 39 | @State private var exporter = false |
| 40 | @State private var workbook: WorkbookExport? |
| 41 | @State private var active = true |
| 42 | @State private var progress = "" |
| 43 | @State private var working = false |
| 44 | var body: some View { |
| 45 | Group { |
| 46 | if importing { |
| 47 | Text("Import an .xlsx or .xlsm workbook up to 20 MB. Only its first worksheet is imported.") |
| 48 | Button("Choose workbook") { picker = true }.disabled(working) |
| 49 | } else { |
| 50 | Button("Save workbook…") { Task { await export() } }.disabled( |
| 51 | working || model.hasUnconfirmedChanges || model.busy) |
| 52 | } |
| 53 | if working { ProgressView() } |
| 54 | if !progress.isEmpty { Text(progress) } |
| 55 | }.fileImporter( |
| 56 | isPresented: $picker, |
| 57 | allowedContentTypes: [ |
| 58 | UTType(filenameExtension: "xlsx") ?? .data, UTType(filenameExtension: "xlsm") ?? .data, |
| 59 | ] |
| 60 | ) { result in |
| 61 | switch result { |
| 62 | case .success(let url): Task { await upload(url) } |
| 63 | case .failure: progress = "Could not open that workbook." |
| 64 | } |
| 65 | } |
| 66 | .fileExporter( |
| 67 | isPresented: $exporter, item: workbook, |
| 68 | contentTypes: [UTType(filenameExtension: "xlsx") ?? .data], |
| 69 | defaultFilename: model.page?.Sheet.Title ?? "Workbook", |
| 70 | onCompletion: { result in |
| 71 | if case .failure = result { progress = "The workbook could not be saved." } |
| 72 | cleanupExport() |
| 73 | }, onCancellation: { cleanupExport() } |
| 74 | ) |
| 75 | .onAppear { active = true } |
| 76 | .onDisappear { |
| 77 | active = false |
| 78 | if !exporter { cleanupExport() } |
| 79 | } |
| 80 | } |
| 81 | private func cleanupExport() { |
| 82 | if let workbook { try? FileManager.default.removeItem(at: workbook.url.deletingLastPathComponent()) } |
| 83 | workbook = nil |
| 84 | } |
| 85 | private func export() async { |
| 86 | guard !model.hasUnconfirmedChanges && !model.busy else { |
| 87 | progress = "Save your cell edit first." |
| 88 | return |
| 89 | } |
| 90 | working = true |
| 91 | defer { working = false } |
| 92 | let directory = FileManager.default.temporaryDirectory |
| 93 | .appendingPathComponent("oficina-sheets-export-" + UUID().uuidString, isDirectory: true) |
| 94 | let title = model.page?.Sheet.Title ?? "Workbook" |
| 95 | let destination = directory.appendingPathComponent(WorkbookExport.filename(for: title)) |
| 96 | do { |
| 97 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, |
| 98 | attributes: [.posixPermissions: 0o700]) |
| 99 | try await model.client.download(model.base + "/export.xlsx", to: destination) |
| 100 | guard active else { |
| 101 | try? FileManager.default.removeItem(at: directory) |
| 102 | return |
| 103 | } |
| 104 | workbook = WorkbookExport(url: destination, title: title) |
| 105 | exporter = true |
| 106 | } catch { |
| 107 | try? FileManager.default.removeItem(at: directory) |
| 108 | progress = error.localizedDescription |
| 109 | } |
| 110 | } |
| 111 | private func upload(_ url: URL) async { |
| 112 | working = true |
| 113 | defer { working = false } |
| 114 | let access = url.startAccessingSecurityScopedResource() |
| 115 | defer { if access { url.stopAccessingSecurityScopedResource() } } |
| 116 | do { |
| 117 | let file = try Data(contentsOf: url, options: .mappedIfSafe) |
| 118 | guard file.count <= 20 << 20 else { |
| 119 | progress = "The workbook exceeds 20 MB." |
| 120 | return |
| 121 | } |
| 122 | let ext = url.pathExtension.lowercased() |
| 123 | guard ext == "xlsx" || ext == "xlsm" else { |
| 124 | progress = "Choose an .xlsx or .xlsm workbook." |
| 125 | return |
| 126 | } |
| 127 | let boundary = UUID().uuidString |
| 128 | let filename = url.lastPathComponent |
| 129 | .replacingOccurrences(of: "\\", with: "\\\\") |
| 130 | .replacingOccurrences(of: "\"", with: "\\\"") |
| 131 | .replacingOccurrences(of: "\r", with: "") |
| 132 | .replacingOccurrences(of: "\n", with: "") |
| 133 | var body = Data( |
| 134 | "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: application/octet-stream\r\n\r\n" |
| 135 | .utf8) |
| 136 | body.append(file) |
| 137 | body.append(Data("\r\n--\(boundary)--\r\n".utf8)) |
| 138 | let started: ImportStarted = try await model.client.request( |
| 139 | "/sheets/import", method: "POST", body: body, |
| 140 | contentType: "multipart/form-data; boundary=\(boundary)") |
| 141 | while !Task.isCancelled { |
| 142 | let state: ImportProgress = try await model.client.request( |
| 143 | "/api/native/jobs/\(started.job)") |
| 144 | progress = state.progress |
| 145 | if state.status == "done" { |
| 146 | progress = "Imported" |
| 147 | await model.loadList() |
| 148 | if let id = UInt64(state.location.split(separator: "/").last ?? ""), |
| 149 | let sheet = model.sheets.first(where: { $0.ID == id }) |
| 150 | { |
| 151 | if let openImported { openImported(sheet.ID, sheet.Title) } |
| 152 | else { await model.open(sheet) } |
| 153 | } |
| 154 | return |
| 155 | } |
| 156 | if state.status == "failed" { |
| 157 | progress = state.error |
| 158 | return |
| 159 | } |
| 160 | try await Task.sleep(for: .seconds(1)) |
| 161 | } |
| 162 | } catch { progress = error.localizedDescription } |
| 163 | } |
| 164 | } |
| 165 | |