oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1import CoreTransferable
2import SwiftUI
3import UniformTypeIdentifiers
4
5struct 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}
27struct ImportStarted: Decodable { let job: String }
28struct ImportProgress: Decodable {
29 let status: String
30 let progress: String
31 let error: String
32 let location: String
33}
34struct 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