oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import Observation
3import RastrilloApple
4
5@MainActor @Observable final class SheetModel {
6 enum ListState { case loading, loaded, failed }
7 var listState = ListState.loading
8 private var listRequest = UUID()
9 var sheets: [SheetSummary] = []
10 var page: SheetPage?
11 var cells: [Coordinate: Cell] = [:]
12 var styles: [String: CellStyle] = [:]
13 var sizes = Sizes()
14 var version: UInt64 = 0
15 var selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 0, c: 0))
16 var rangeText = "A1"
17 var gridFocused = false
18 var draft = ""
19 var originalDraft = ""
20 var dirty: Bool { draft != originalDraft }
21 var failedWrites: [CellWrite] = []
22 var hasUnconfirmedChanges: Bool { dirty || !failedWrites.isEmpty }
23 var busy = false
24 var error = ""
25 var notice = ""
26 var live = "Connecting"
27 var lastNotice = ""
28 var history: [HistoryEntry] = []
29 var table: TablePage?
30 var view = ViewSpec()
31 var tablePage = 1
32 var showTable = false
33 var copied: [[CellWrite]]?
34 var copiedText = ""
35 let wire = SheetWireWorker()
36 let client: NativeTransport
37 /// The live stream waits on this between attempts, so a restored network
38 /// reconnects at once and an offline device makes no timed attempts.
39 let connectivity: NativeConnectivity
40 /// Monotonic seconds for the live stream's pacing; tests substitute a fake.
41 var reconnectClock: @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
42 init(client: NativeTransport, connectivity: NativeConnectivity? = nil) {
43 self.client = client
44 self.connectivity = connectivity ?? .shared
45 }
46 var base: String { "/sheets/\(page?.Sheet.ID ?? 0)" }
47 var grid: String { "/api\(base)/tabs/\(page?.Sheet.TabID ?? 0)" }
48 var rows: Int { page?.Rows ?? 100 }
49 var cols: Int { page?.Cols ?? 26 }
50 var mayWrite: Bool { page?.MayWrite == true }
51 func loadList() async {
52 let request = UUID(); listRequest = request
53 listState = .loading
54 do {
55 let response: SheetList = try await client.request("/sheets")
56 guard listRequest == request, !Task.isCancelled else { return }
57 sheets = response.Sheets ?? []
58 listState = .loaded; error = ""
59 } catch {
60 guard listRequest == request else { return }
61 listState = .failed; self.error = error.localizedDescription
62 }
63 }
64 func open(_ sheet: SheetSummary) async {
65 guard !hasUnconfirmedChanges && !busy else {
66 error = "Save or discard your cell edit before changing sheets."
67 return
68 }
69 busy = true
70 defer { busy = false }
71 do {
72 let value: SheetPage = try await client.request("/sheets/\(sheet.ID)")
73 clearCopyProvenance()
74 page = value
75 version = 0
76 cells = [:]
77 styles = [:]
78 table = nil
79 history = []
80 showTable = false
81 await refresh()
82 busy = false
83 select(Coordinate(r: 0, c: 0))
84 } catch { self.error = error.localizedDescription }
85 }
86 func refresh() async {
87 guard page != nil else { return }
88 let source = grid
89 do {
90 var updated: [Coordinate: Cell] = [:]
91 var formats: [String: CellStyle] = [:]
92 var fetchedVersion: UInt64 = .max
93 var fetchedSizes = Sizes()
94 for firstRow in stride(from: 0, to: rows, by: 25) {
95 for firstCol in stride(from: 0, to: cols, by: 50) {
96 try Task.checkCancellation()
97 let path =
98 grid
99 + "/cells?firstRow=\(firstRow)&firstCol=\(firstCol)&rows=\(min(25,rows-firstRow))&cols=\(min(50,cols-firstCol))"
100 let (data, status) = try await client.response(path, maxBytes: 64 << 20)
101 guard status == 200 else {
102 throw NativeClientError.message("The sheet could not be refreshed.")
103 }
104 let response = try await wire.decode(data)
105 guard !Task.isCancelled, grid == source else { return }
106 fetchedVersion = min(fetchedVersion, response.version)
107 for cell in response.cells ?? [] { updated[cell.coordinate] = cell }
108 formats.merge(response.formats ?? [:]) { _, new in new }
109 fetchedSizes = response.sizes ?? Sizes()
110 }
111 }
112 // SSE delivery remains buffered while onOpen awaits this snapshot.
113 if fetchedVersion >= version {
114 version = fetchedVersion
115 cells = updated
116 styles = formats
117 sizes = fetchedSizes
118 refreshDraftIfClean()
119 }
120 } catch is CancellationError { return } catch { self.error = error.localizedDescription }
121 }
122
123 func accept(_ response: GridResponse) async {
124 guard response.version >= version else { return }
125 // A write of N cells moves the version by N and says so in `stamps`.
126 // Stampless responses (format, size, snapshot) report the server's
127 // NEXT stamp, hence the minimum of one. A wider jump is a missed
128 // edit. The server's TestNativeVersionContractAgainstRealResponses
129 // replays real responses through a Go mirror of this rule.
130 let allowed = UInt64(max(response.stamps ?? 0, 1))
131 if response.resync == true || response.version > version + allowed {
132 await refresh()
133 return
134 }
135 if let changed = response.cells, !changed.isEmpty {
136 var nextCells = cells
137 for cell in changed { nextCells[cell.coordinate] = cell }
138 cells = nextCells
139 }
140 if let formats = response.formats, !formats.isEmpty {
141 var nextStyles = styles
142 nextStyles.merge(formats) { _, new in new }
143 styles = nextStyles
144 }
145 version = response.version
146 if let sizes = response.sizes { self.sizes = sizes }
147 refreshDraftIfClean()
148 }
149 private func refreshDraftIfClean() {
150 guard !dirty else { return }
151 originalDraft = cells[selection.start]?.raw ?? ""
152 draft = originalDraft
153 }
154 func select(_ point: Coordinate) {
155 guard !hasUnconfirmedChanges && !busy else {
156 error = "Save or discard this edit before selecting another cell."
157 return
158 }
159 selection = CellRange(point, point)
160 rangeText = point.ref
161 originalDraft = cells[point]?.raw ?? ""
162 draft = originalDraft
163 }
164 func selectRange() {
165 guard !hasUnconfirmedChanges && !busy, let range = CellRange.parse(rangeText),
166 range.end.r < rows, range.end.c < cols
167 else {
168 error = "Choose a range inside the visible sheet, after saving your edit."
169 return
170 }
171 selection = range
172 originalDraft = cells[range.start]?.raw ?? ""
173 draft = originalDraft
174 }
175 func discardAll() {
176 failedWrites = []
177 discard()
178 }
179 func saveAll() async {
180 if !failedWrites.isEmpty { await retryWrites() }
181 if failedWrites.isEmpty && dirty { await commit() }
182 }
183 func discard() {
184 originalDraft = cells[selection.start]?.raw ?? ""
185 draft = originalDraft
186 error = ""
187 }
188 func commit(moveRight: Bool = false) async {
189 let point = selection.start
190 let saved = dirty ? await write([CellWrite(r: point.r, c: point.c, raw: draft)]) : true
191 if saved {
192 originalDraft = draft
193 select(
194 Coordinate(
195 r: moveRight ? point.r : min(rows - 1, point.r + 1),
196 c: moveRight ? min(cols - 1, point.c + 1) : point.c))
197 }
198 }
199
200 func format(_ values: [String: Any]) async {
201 guard mayWrite && !busy && !hasUnconfirmedChanges else { return }
202 var values = values
203 values["r1"] = selection.start.r
204 values["c1"] = selection.start.c
205 values["r2"] = selection.end.r
206 values["c2"] = selection.end.c
207 busy = true
208 defer { busy = false }
209 do {
210 let value: GridResponse = try await client.request(
211 grid + "/format", method: "POST", body: JSONSerialization.data(withJSONObject: values))
212 await accept(value)
213 } catch { self.error = error.localizedDescription }
214 }
215 func resize(axis: String, size: Int, index: Int? = nil) async {
216 guard mayWrite && !busy && !hasUnconfirmedChanges else { return }
217 busy = true
218 defer { busy = false }
219 do {
220 let (bytes, status) = try await client.response(
221 grid + "/size", method: "POST",
222 body: JSONSerialization.data(withJSONObject: [
223 axis: index ?? (axis == "col" ? selection.start.c : selection.start.r), "size": size,
224 ]), maxBytes: 64 << 20)
225 guard (200..<300).contains(status) else {
226 throw NativeClientError.message(sheetsMacString("The server did not confirm the resize."))
227 }
228 let response = try await wire.decode(bytes)
229 await accept(response)
230 } catch { self.error = error.localizedDescription }
231 }
232 func form<T: Decodable>(_ path: String, _ fields: [String: String]) async throws -> T {
233 var parts = URLComponents()
234 parts.queryItems = fields.sorted(by: { $0.key < $1.key }).map {
235 URLQueryItem(name: $0.key, value: $0.value)
236 }
237 let body = (parts.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
238 let (bytes, status) = try await client.response(
239 path, method: "POST", body: Data(body.utf8), contentType: "application/x-www-form-urlencoded",
240 maxBytes: 64 << 20)
241 guard (200..<300).contains(status) else {
242 if let reply = try? JSONDecoder().decode(Notice.self, from: bytes) {
243 throw NativeClientError.message(reply.message)
244 }
245 throw NativeClientError.message(
246 "The request was not confirmed. Check your access and try again.")
247 }
248 return try JSONDecoder().decode(T.self, from: bytes)
249 }
250 @discardableResult func command(_ suffix: String, _ fields: [String: String] = [:]) async -> Bool
251 {
252 guard !busy && !hasUnconfirmedChanges else {
253 error = "Save or discard the current edit first."
254 return false
255 }
256 lastNotice = ""
257 busy = true
258 defer { busy = false }
259 do {
260 let value: Notice = try await form(base + suffix, fields)
261 lastNotice = value.notice
262 notice = value.message
263 await refresh()
264 return true
265 } catch {
266 self.error = error.localizedDescription
267 return false
268 }
269 }
270 @discardableResult func create(openCreated: Bool = true) async -> SheetSummary? {
271 guard !hasUnconfirmedChanges && !busy else {
272 error = "Save or discard the current edit first."
273 return nil
274 }
275 busy = true
276 defer { busy = false }
277 do {
278 let sheet: SheetSummary = try await form("/sheets", [:])
279 await loadList()
280 busy = false
281 if openCreated { await open(sheet) }
282 return sheet
283 } catch { self.error = error.localizedDescription; return nil }
284 }
285 func loadHistory() async {
286 let sheetID = page?.Sheet.ID
287 do {
288 let value: HistoryPage = try await client.request(base + "/history")
289 guard page?.Sheet.ID == sheetID else { return }
290 history = value.Entries ?? []
291 } catch { self.error = error.localizedDescription }
292 }
293 func loadTable() async {
294 let sheetID = page?.Sheet.ID
295 do {
296 let value: TablePage = try await client.request(base + "/table?page=\(tablePage)")
297 guard page?.Sheet.ID == sheetID else { return }
298 table = value
299 view = value.View
300 } catch { self.error = error.localizedDescription }
301 }
302 func saveView() async -> Bool {
303 guard
304 await command(
305 "/view",
306 [
307 "sortCol": String(view.sortCol), "sortDesc": view.sortDesc ? "1" : "",
308 "filterCol": String(view.filterCol), "filterOp": view.filterOp,
309 "filterVal": view.filterVal,
310 ])
311 else { return false }
312 await loadTable()
313 return true
314 }
315 func insertFunction(_ name: String) async {
316 let p = selection.start
317 var top = p.r
318 while top > 0 && !(cells[Coordinate(r: top - 1, c: p.c)]?.raw ?? "").isEmpty { top -= 1 }
319 let arg =
320 top < p.r ? Coordinate(r: top, c: p.c).ref + ":" + Coordinate(r: p.r - 1, c: p.c).ref : ""
321 _ = await write([CellWrite(r: p.r, c: p.c, raw: "=\(name)(\(arg))")])
322 }
323}
324