oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "encoding/json"
5
6 "amadan.net/oficina/sheets/internal/sheets/calc"
7 "amadan.net/oficina/sheets/internal/sheets/format"
8 "amadan.net/oficina/sheets/internal/sheets/store"
9)
10
11// gridCell is one cell on the wire.
12//
13// R and C are grid POSITIONS, not stable ids. The client thinks in
14// coordinates — it is painting a grid — and stable ids are a storage
15// concern it has no use for. The translation happens here, in one
16// place, rather than leaking row and column ids into JavaScript where
17// they would have to be kept in sync with every insert.
18//
19// Raw and Display are both sent, always. A client with only Display
20// replaces "=A1*2" with "42" in the editor the moment somebody else's
21// edit arrives; a client with only Raw has to evaluate formulas itself,
22// which is the one thing this design will not have it do.
23type gridCell struct {
24 R int `json:"r"`
25 C int `json:"c"`
26 Raw string `json:"raw"`
27 Display string `json:"display"`
28 Derived bool `json:"derived,omitempty"`
29 // Num marks a cell whose COMPUTED value is a number, so the client
30 // can right-align it.
31 //
32 // It is a property of the value, not of the input: "=1+1" is a
33 // number and "1a" is text, and only the engine knows which. The
34 // alternative — letting the client sniff the display string — puts
35 // a second, worse number parser in JavaScript, and it would
36 // disagree with this one the first time a number format put a
37 // currency symbol in front of the digits.
38 Num bool `json:"n,omitempty"`
39 // F is the id of this cell's interned format, into the response's
40 // own Formats table. Empty for an unformatted cell — the common
41 // case, and the reason this is a key rather than an inline object.
42 F string `json:"f,omitempty"`
43}
44
45// gridResponse is what both GET and POST answer, and what the SSE
46// stream carries. One shape, so the client has one function that
47// applies a batch of cells and does not care where it came from.
48//
49// Version orders paints (spec §7). Resync tells a client that this
50// response is not the whole truth and it should refetch its window —
51// set when a write's dependent set was too wide to recalculate
52// synchronously.
53//
54// Client is the id the poster sent, echoed back so a tab can skip its
55// own edit arriving over the stream. Without it, the tab you typed in
56// repaints the cell you are still editing.
57type gridResponse struct {
58 Version uint64 `json:"version"`
59 // omitempty: a write response describes changed cells, not a
60 // window, and "rows":0,"cols":0 on every edit is a claim about a
61 // window nobody asked for.
62 Rows int `json:"rows,omitempty"`
63 Cols int `json:"cols,omitempty"`
64 Cells []gridCell `json:"cells"`
65 Resync bool `json:"resync,omitempty"`
66 // Stamps is how many versions the write behind this response
67 // consumed (store.Report.Stamps): 1 for a cell, N for an N-cell
68 // batch, absent for format, size and snapshot responses, which
69 // consume none. A client holding version H missed an edit iff
70 // Version > H + max(Stamps, 1); the one of slack is because those
71 // stampless responses report the NEXT stamp (Handle.Version), not
72 // the last one issued. Native's SheetModel.accept is the reader;
73 // grid.js only drops older payloads and ignores it.
74 Stamps int `json:"stamps,omitempty"`
75 Client string `json:"client,omitempty"`
76 // Formats is every distinct style the cells above refer to, keyed
77 // by the id each cell's F names. Absent when nothing in the batch
78 // is formatted.
79 //
80 // A batch carries only the formats IT mentions, not the tab's whole
81 // table, so a client must merge rather than replace — see grid.js.
82 // Replacing would drop every style outside the edited cell the
83 // first time somebody typed into an imported sheet.
84 Formats styleTable `json:"formats,omitempty"`
85 // Sizes is the tab's column widths and row heights, sent whole
86 // rather than as a delta — see sizesResponse. Absent on a batch
87 // that is not about layout, which is almost all of them.
88 Sizes *wireSizes `json:"sizes,omitempty"`
89}
90
91// display turns a computed value into the string the grid paints.
92//
93// The server owns every string on screen. That is spec §12's gate — at
94// least one check crossing from computed value to painted output — and
95// it is also what keeps the client from needing a number-format
96// implementation that could disagree with the exporter's.
97// isNumber reports whether a computed value should read as a number.
98//
99// Booleans are excluded deliberately: Excel centres TRUE and FALSE, and
100// they are not quantities. Errors are excluded for the same reason.
101func isNumber(v calc.Value) bool { return v.Kind == calc.KindNum }
102
103func display(v calc.Value, f *store.Format) string {
104 numFmt := ""
105 if f != nil {
106 numFmt = f.NumberFmt
107 }
108 return format.Display(v, numFmt)
109}
110
111// posIndex maps stable ids to grid positions, both ways round.
112//
113// Built once per request from the handle's own row and column records.
114// A map rather than a scan because a 100×26 window asks 2,600 times and
115// a linear scan per cell turns an O(n) render into O(n²).
116type posIndex struct {
117 rowPos map[uint64]int
118 colPos map[uint64]int
119 rowID map[int]uint64
120 colID map[int]uint64
121}
122
123func newPosIndex(h *store.Handle) posIndex {
124 idx := posIndex{
125 rowPos: map[uint64]int{}, colPos: map[uint64]int{},
126 rowID: map[int]uint64{}, colID: map[int]uint64{},
127 }
128 for _, r := range h.GridRows() {
129 idx.rowPos[r.ID] = r.Pos
130 idx.rowID[r.Pos] = r.ID
131 }
132 for _, c := range h.GridCols() {
133 idx.colPos[c.ID] = c.Pos
134 idx.colID[c.Pos] = c.ID
135 }
136 return idx
137}
138
139// extent is how far a tab's content actually reaches: one past the
140// furthest row and column that hold a cell.
141//
142// It exists because the grid page opened a CONSTANT window. 100 rows is
143// the right default for a new sheet and the wrong answer for one whose
144// content runs to row 151 — the cell route accepts that write, the table
145// view lists it and the export carries it, and the grid simply had no
146// row 151 to scroll to. Nothing was lost and nothing said anything.
147//
148// Positions, not ids: a cell records the row and column it belongs to by
149// id, and only the index knows where those sit. A row record with no
150// cells in it does not count, which is deliberate — EnsureRow is called
151// on the way to a write, so counting rows rather than cells would widen
152// the window by whatever a failed write left behind.
153func extent(h *store.Handle, idx posIndex) (rows, cols int) {
154 coords, err := h.Cells()
155 if err != nil {
156 return 0, 0
157 }
158 for _, c := range coords {
159 if r, ok := idx.rowPos[c.RowID]; ok && r+1 > rows {
160 rows = r + 1
161 }
162 if col, ok := idx.colPos[c.ColID]; ok && col+1 > cols {
163 cols = col + 1
164 }
165 }
166 return rows, cols
167}
168
169// snapshot reads every cell inside a window.
170//
171// Sparse: only cells that exist are sent. A blank cell is absent from
172// the response rather than present and empty, which is the difference
173// between a 100×26 window costing two kilobytes and costing sixty.
174func snapshot(h *store.Handle, idx posIndex, rows, cols int) ([]gridCell, styleTable, error) {
175 return snapshotWindow(h, idx, 0, 0, rows, cols)
176}
177
178func snapshotWindow(h *store.Handle, idx posIndex, firstRow, firstCol, rows, cols int) ([]gridCell, styleTable, error) {
179 coords, err := h.Cells()
180 if err != nil {
181 return nil, nil, err
182 }
183 // One batched read for the whole window, not one query per cell:
184 // see store.Handle.Formats for why Handle.Format is the wrong shape
185 // here now that every cell's style is painted.
186 formats, err := h.Formats()
187 if err != nil {
188 return nil, nil, err
189 }
190 col := newCollector(formats)
191 out := make([]gridCell, 0, len(coords))
192 for _, c := range coords {
193 r, ok := idx.rowPos[c.RowID]
194 if !ok || r < firstRow || r >= firstRow+rows {
195 continue
196 }
197 colPos, ok := idx.colPos[c.ColID]
198 if !ok || colPos < firstCol || colPos >= firstCol+cols {
199 continue
200 }
201 raw, _ := h.RawInput(c.RowID, c.ColID)
202 out = append(out, gridCell{
203 R: r, C: colPos,
204 Raw: raw,
205 Display: display(h.Value(c.RowID, c.ColID), col.numFmtFor(c.RowID, c.ColID)),
206 Num: isNumber(h.Value(c.RowID, c.ColID)),
207 F: col.keyFor(c.RowID, c.ColID),
208 })
209 }
210 return out, col.done(), nil
211}
212
213// fromReport turns a write's report into wire cells.
214//
215// A reported cell whose row or column is somehow not in the index is
216// dropped rather than sent with a wrong coordinate: a cell painted in
217// the wrong place is worse than a cell not painted, because nothing
218// later corrects it.
219func fromReport(rep store.Report, idx posIndex, h *store.Handle) ([]gridCell, styleTable) {
220 formats, err := h.Formats()
221 if err != nil {
222 // A format read that fails costs styling on this batch, not the
223 // edit itself. The values are what the person typed; painting
224 // them unstyled and letting the next snapshot correct it beats
225 // failing a write that already landed in storage.
226 formats = nil
227 }
228 col := newCollector(formats)
229 out := make([]gridCell, 0, len(rep.Cells))
230 for _, c := range rep.Cells {
231 r, ok := idx.rowPos[c.RowID]
232 if !ok {
233 continue
234 }
235 colPos, ok := idx.colPos[c.ColID]
236 if !ok {
237 continue
238 }
239 out = append(out, gridCell{
240 R: r, C: colPos,
241 Raw: c.Raw,
242 Display: display(c.Value, col.numFmtFor(c.RowID, c.ColID)),
243 Derived: c.Derived,
244 Num: isNumber(c.Value),
245 F: col.keyFor(c.RowID, c.ColID),
246 })
247 }
248 return out, col.done()
249}
250
251// clamp bounds a requested window.
252//
253// Clamped, not refused: a request for a million rows is a client bug or
254// a curious operator, and answering 400 turns either into a blank page
255// where answering with 500 rows turns it into a working one.
256func clamp(want, def, max int) int {
257 if want <= 0 {
258 return def
259 }
260 if want > max {
261 return max
262 }
263 return want
264}
265
266// marshalGrid encodes a response for the wire.
267//
268// Errors are impossible for this shape — every field is a string, an
269// int or a bool — but the signature keeps the caller honest rather than
270// hiding a discarded error behind an underscore at the point where cell
271// content would be the thing lost.
272func marshalGrid(resp gridResponse) ([]byte, error) { return json.Marshal(resp) }
273