| 1 | import Foundation |
| 2 | import Observation |
| 3 | import 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 | |