import Foundation import Observation import RastrilloApple @MainActor @Observable final class SheetModel { enum ListState { case loading, loaded, failed } var listState = ListState.loading private var listRequest = UUID() var sheets: [SheetSummary] = [] var page: SheetPage? var cells: [Coordinate: Cell] = [:] var styles: [String: CellStyle] = [:] var sizes = Sizes() var version: UInt64 = 0 var selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 0, c: 0)) var rangeText = "A1" var gridFocused = false var draft = "" var originalDraft = "" var dirty: Bool { draft != originalDraft } var failedWrites: [CellWrite] = [] var hasUnconfirmedChanges: Bool { dirty || !failedWrites.isEmpty } var busy = false var error = "" var notice = "" var live = "Connecting" var lastNotice = "" var history: [HistoryEntry] = [] var table: TablePage? var view = ViewSpec() var tablePage = 1 var showTable = false var copied: [[CellWrite]]? var copiedText = "" let wire = SheetWireWorker() let client: NativeTransport /// The live stream waits on this between attempts, so a restored network /// reconnects at once and an offline device makes no timed attempts. let connectivity: NativeConnectivity /// Monotonic seconds for the live stream's pacing; tests substitute a fake. var reconnectClock: @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } init(client: NativeTransport, connectivity: NativeConnectivity? = nil) { self.client = client self.connectivity = connectivity ?? .shared } var base: String { "/sheets/\(page?.Sheet.ID ?? 0)" } var grid: String { "/api\(base)/tabs/\(page?.Sheet.TabID ?? 0)" } var rows: Int { page?.Rows ?? 100 } var cols: Int { page?.Cols ?? 26 } var mayWrite: Bool { page?.MayWrite == true } func loadList() async { let request = UUID(); listRequest = request listState = .loading do { let response: SheetList = try await client.request("/sheets") guard listRequest == request, !Task.isCancelled else { return } sheets = response.Sheets ?? [] listState = .loaded; error = "" } catch { guard listRequest == request else { return } listState = .failed; self.error = error.localizedDescription } } func open(_ sheet: SheetSummary) async { guard !hasUnconfirmedChanges && !busy else { error = "Save or discard your cell edit before changing sheets." return } busy = true defer { busy = false } do { let value: SheetPage = try await client.request("/sheets/\(sheet.ID)") clearCopyProvenance() page = value version = 0 cells = [:] styles = [:] table = nil history = [] showTable = false await refresh() busy = false select(Coordinate(r: 0, c: 0)) } catch { self.error = error.localizedDescription } } func refresh() async { guard page != nil else { return } let source = grid do { var updated: [Coordinate: Cell] = [:] var formats: [String: CellStyle] = [:] var fetchedVersion: UInt64 = .max var fetchedSizes = Sizes() for firstRow in stride(from: 0, to: rows, by: 25) { for firstCol in stride(from: 0, to: cols, by: 50) { try Task.checkCancellation() let path = grid + "/cells?firstRow=\(firstRow)&firstCol=\(firstCol)&rows=\(min(25,rows-firstRow))&cols=\(min(50,cols-firstCol))" let (data, status) = try await client.response(path, maxBytes: 64 << 20) guard status == 200 else { throw NativeClientError.message("The sheet could not be refreshed.") } let response = try await wire.decode(data) guard !Task.isCancelled, grid == source else { return } fetchedVersion = min(fetchedVersion, response.version) for cell in response.cells ?? [] { updated[cell.coordinate] = cell } formats.merge(response.formats ?? [:]) { _, new in new } fetchedSizes = response.sizes ?? Sizes() } } // SSE delivery remains buffered while onOpen awaits this snapshot. if fetchedVersion >= version { version = fetchedVersion cells = updated styles = formats sizes = fetchedSizes refreshDraftIfClean() } } catch is CancellationError { return } catch { self.error = error.localizedDescription } } func accept(_ response: GridResponse) async { guard response.version >= version else { return } // A write of N cells moves the version by N and says so in `stamps`. // Stampless responses (format, size, snapshot) report the server's // NEXT stamp, hence the minimum of one. A wider jump is a missed // edit. The server's TestNativeVersionContractAgainstRealResponses // replays real responses through a Go mirror of this rule. let allowed = UInt64(max(response.stamps ?? 0, 1)) if response.resync == true || response.version > version + allowed { await refresh() return } if let changed = response.cells, !changed.isEmpty { var nextCells = cells for cell in changed { nextCells[cell.coordinate] = cell } cells = nextCells } if let formats = response.formats, !formats.isEmpty { var nextStyles = styles nextStyles.merge(formats) { _, new in new } styles = nextStyles } version = response.version if let sizes = response.sizes { self.sizes = sizes } refreshDraftIfClean() } private func refreshDraftIfClean() { guard !dirty else { return } originalDraft = cells[selection.start]?.raw ?? "" draft = originalDraft } func select(_ point: Coordinate) { guard !hasUnconfirmedChanges && !busy else { error = "Save or discard this edit before selecting another cell." return } selection = CellRange(point, point) rangeText = point.ref originalDraft = cells[point]?.raw ?? "" draft = originalDraft } func selectRange() { guard !hasUnconfirmedChanges && !busy, let range = CellRange.parse(rangeText), range.end.r < rows, range.end.c < cols else { error = "Choose a range inside the visible sheet, after saving your edit." return } selection = range originalDraft = cells[range.start]?.raw ?? "" draft = originalDraft } func discardAll() { failedWrites = [] discard() } func saveAll() async { if !failedWrites.isEmpty { await retryWrites() } if failedWrites.isEmpty && dirty { await commit() } } func discard() { originalDraft = cells[selection.start]?.raw ?? "" draft = originalDraft error = "" } func commit(moveRight: Bool = false) async { let point = selection.start let saved = dirty ? await write([CellWrite(r: point.r, c: point.c, raw: draft)]) : true if saved { originalDraft = draft select( Coordinate( r: moveRight ? point.r : min(rows - 1, point.r + 1), c: moveRight ? min(cols - 1, point.c + 1) : point.c)) } } func format(_ values: [String: Any]) async { guard mayWrite && !busy && !hasUnconfirmedChanges else { return } var values = values values["r1"] = selection.start.r values["c1"] = selection.start.c values["r2"] = selection.end.r values["c2"] = selection.end.c busy = true defer { busy = false } do { let value: GridResponse = try await client.request( grid + "/format", method: "POST", body: JSONSerialization.data(withJSONObject: values)) await accept(value) } catch { self.error = error.localizedDescription } } func resize(axis: String, size: Int, index: Int? = nil) async { guard mayWrite && !busy && !hasUnconfirmedChanges else { return } busy = true defer { busy = false } do { let (bytes, status) = try await client.response( grid + "/size", method: "POST", body: JSONSerialization.data(withJSONObject: [ axis: index ?? (axis == "col" ? selection.start.c : selection.start.r), "size": size, ]), maxBytes: 64 << 20) guard (200..<300).contains(status) else { throw NativeClientError.message(sheetsMacString("The server did not confirm the resize.")) } let response = try await wire.decode(bytes) await accept(response) } catch { self.error = error.localizedDescription } } func form(_ path: String, _ fields: [String: String]) async throws -> T { var parts = URLComponents() parts.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } let body = (parts.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B") let (bytes, status) = try await client.response( path, method: "POST", body: Data(body.utf8), contentType: "application/x-www-form-urlencoded", maxBytes: 64 << 20) guard (200..<300).contains(status) else { if let reply = try? JSONDecoder().decode(Notice.self, from: bytes) { throw NativeClientError.message(reply.message) } throw NativeClientError.message( "The request was not confirmed. Check your access and try again.") } return try JSONDecoder().decode(T.self, from: bytes) } @discardableResult func command(_ suffix: String, _ fields: [String: String] = [:]) async -> Bool { guard !busy && !hasUnconfirmedChanges else { error = "Save or discard the current edit first." return false } lastNotice = "" busy = true defer { busy = false } do { let value: Notice = try await form(base + suffix, fields) lastNotice = value.notice notice = value.message await refresh() return true } catch { self.error = error.localizedDescription return false } } @discardableResult func create(openCreated: Bool = true) async -> SheetSummary? { guard !hasUnconfirmedChanges && !busy else { error = "Save or discard the current edit first." return nil } busy = true defer { busy = false } do { let sheet: SheetSummary = try await form("/sheets", [:]) await loadList() busy = false if openCreated { await open(sheet) } return sheet } catch { self.error = error.localizedDescription; return nil } } func loadHistory() async { let sheetID = page?.Sheet.ID do { let value: HistoryPage = try await client.request(base + "/history") guard page?.Sheet.ID == sheetID else { return } history = value.Entries ?? [] } catch { self.error = error.localizedDescription } } func loadTable() async { let sheetID = page?.Sheet.ID do { let value: TablePage = try await client.request(base + "/table?page=\(tablePage)") guard page?.Sheet.ID == sheetID else { return } table = value view = value.View } catch { self.error = error.localizedDescription } } func saveView() async -> Bool { guard await command( "/view", [ "sortCol": String(view.sortCol), "sortDesc": view.sortDesc ? "1" : "", "filterCol": String(view.filterCol), "filterOp": view.filterOp, "filterVal": view.filterVal, ]) else { return false } await loadTable() return true } func insertFunction(_ name: String) async { let p = selection.start var top = p.r while top > 0 && !(cells[Coordinate(r: top - 1, c: p.c)]?.raw ?? "").isEmpty { top -= 1 } let arg = top < p.r ? Coordinate(r: top, c: p.c).ref + ":" + Coordinate(r: p.r - 1, c: p.c).ref : "" _ = await write([CellWrite(r: p.r, c: p.c, raw: "=\(name)(\(arg))")]) } }