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 "net/http"
6 "strconv"
7
8 "amadan.net/oficina/sheets/internal/sheets/store"
9)
10
11// sizeWrite is the POST body: one column's width or one row's height.
12//
13// Exactly one of Col and Row is set, and the handler refuses both or
14// neither rather than picking. A request that names two things is a
15// client bug, and guessing which it meant is how the wrong column ends
16// up resized on the day somebody's serialiser starts emitting zeroes
17// for absent fields.
18type sizeWrite struct {
19 Col *int `json:"col,omitempty"`
20 Row *int `json:"row,omitempty"`
21 Size int `json:"size"`
22 Client string `json:"client"`
23}
24
25// handleSizePost stores a column width or a row height.
26//
27// It answers with a sizes payload and publishes the same one, so a
28// column widened in one tab widens in every other tab watching the
29// sheet — the same live-sync contract every other edit has. A layout
30// change that did not propagate would leave two people looking at
31// visibly different documents and no way to tell which was right.
32//
33// Like formatting, a resize writes no event and is therefore not
34// undoable. Same reason, same named limitation.
35func (a *App) handleSizePost(w http.ResponseWriter, r *http.Request) {
36 if _, ok := a.actor(r); !ok {
37 http.Error(w, "Sign in first.", http.StatusForbidden)
38 return
39 }
40 h, ok := a.openTab(w, r, SheetEditor)
41 if !ok {
42 return
43 }
44 defer h.Release()
45
46 // One route, two decoders, converging on one sizeWrite before
47 // anything is checked — the same shape handleFormatPost took, and
48 // for the same reason: the bounds, the exactly-one rule and the
49 // store call must exist once. The scriptless table view posts a
50 // form here; the grid posts JSON.
51 var in sizeWrite
52 form := isFormPost(r)
53 if form {
54 r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
55 var ok bool
56 if in, ok = sizeFromForm(r); !ok {
57 http.Error(w, "Malformed request.", http.StatusBadRequest)
58 return
59 }
60 } else if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
61 http.Error(w, "Malformed request.", http.StatusBadRequest)
62 return
63 }
64 if (in.Col == nil) == (in.Row == nil) {
65 http.Error(w, "Name exactly one of col and row.", http.StatusBadRequest)
66 return
67 }
68
69 var err error
70 switch {
71 case in.Col != nil:
72 if *in.Col < 0 || *in.Col >= maxWindowCols*10 {
73 http.Error(w, "Column out of range.", http.StatusBadRequest)
74 return
75 }
76 var colID uint64
77 if colID, err = h.EnsureCol(*in.Col); err == nil {
78 err = h.SetColWidth(colID, in.Size)
79 }
80 default:
81 if *in.Row < 0 || *in.Row >= maxWindowRows*10 {
82 http.Error(w, "Row out of range.", http.StatusBadRequest)
83 return
84 }
85 var rowID uint64
86 if rowID, err = h.EnsureRow(*in.Row); err == nil {
87 err = h.SetRowHeight(rowID, in.Size)
88 }
89 }
90 if err != nil {
91 // A size outside the bounds is the caller's fault and a storage
92 // failure is ours. Both answer the same fixed string: the error
93 // carries a coordinate the caller already knows and nothing
94 // else, because it is one hop from a log line (AGENTS.md).
95 http.Error(w, "That size could not be applied.", http.StatusBadRequest)
96 return
97 }
98
99 resp, err := sizesResponse(h, in.Client)
100 if err != nil {
101 a.fail(w, r, err)
102 return
103 }
104 a.touchSheet(sheetIDFrom(r))
105 a.publish(tabIDFrom(r), resp)
106 if form {
107 // 303 back to the table, built here from the sheet id in the
108 // path and a page this bounds itself — never from the request.
109 // Same reasoning as the format route's redirect.
110 http.Redirect(w, r, tableReturn(sheetIDFrom(r), r.FormValue("page")), http.StatusSeeOther)
111 return
112 }
113 writeJSON(w, http.StatusOK, resp)
114}
115
116// sizeFromForm decodes a sizeWrite out of an ordinary HTML form.
117//
118// Nothing is validated here. The exactly-one-of-col-and-row rule, the
119// bounds and the store call all run afterwards in the handler on
120// whichever request arrived — so the form path and the JSON path cannot
121// drift into two rule sets, which is the same decision formatFromForm
122// records.
123//
124// Presence, not value, decides which of col and row was named: an
125// absent field and a field holding "0" are different requests, and
126// column 0 is a real column. That is the same reason the JSON shape
127// uses pointers.
128func sizeFromForm(r *http.Request) (sizeWrite, bool) {
129 var in sizeWrite
130 if err := r.ParseForm(); err != nil {
131 return in, false
132 }
133 read := func(name string) (*int, bool) {
134 if _, present := r.Form[name]; !present {
135 return nil, true
136 }
137 n, err := strconv.Atoi(r.FormValue(name))
138 if err != nil {
139 return nil, false
140 }
141 return &n, true
142 }
143 col, ok := read("col")
144 if !ok {
145 return in, false
146 }
147 row, ok := read("row")
148 if !ok {
149 return in, false
150 }
151 size, err := strconv.Atoi(r.FormValue("size"))
152 if err != nil {
153 return in, false
154 }
155 in.Col, in.Row, in.Size = col, row, size
156 return in, true
157}
158
159// sizesResponse builds a payload carrying the tab's sizes and no cells.
160//
161// The WHOLE size table, not just the one that changed. It is one small
162// map — only sized rows and columns appear in it — and sending all of
163// it means the client applies rather than merges, so a tab that missed
164// a frame while disconnected converges on the next one it sees instead
165// of staying one resize behind forever.
166func sizesResponse(h *store.Handle, client string) (gridResponse, error) {
167 cols, rows, err := h.Sizes()
168 if err != nil {
169 return gridResponse{}, err
170 }
171 return gridResponse{
172 // Unchanged: a resize appends no event, so nothing has advanced
173 // the tab's version. Sending the current one rather than zero
174 // matters — a client discards a batch whose version trails what
175 // it has painted.
176 Version: h.Version(),
177 Cells: []gridCell{},
178 Sizes: &wireSizes{Cols: intKeys(cols), Rows: intKeys(rows)},
179 Client: client,
180 }, nil
181}
182
183// wireSizes is a tab's column widths and row heights on the wire.
184//
185// Keyed by position rendered as a string, because JSON object keys are
186// strings — the same shape and the same reason as the format table.
187type wireSizes struct {
188 Cols map[string]int `json:"cols,omitempty"`
189 Rows map[string]int `json:"rows,omitempty"`
190}
191
192func intKeys(m map[int]int) map[string]int {
193 if len(m) == 0 {
194 return nil
195 }
196 out := make(map[string]int, len(m))
197 for k, v := range m {
198 // itoaPos, comments.go — a grid position rendered the same way
199 // the A1 references are, so a key means the same thing on every
200 // wire this app has.
201 out[itoaPos(k)] = v
202 }
203 return out
204}
205