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 "net/http"
5 "strconv"
6 "strings"
7
8 "amadan.net/oficina/sheets/internal/sheets/store"
9)
10
11// The scriptless table's page size.
12//
13// Spec §6: the table view is paginated, "because a 200,000-cell tab
14// rendered as one HTML table is a several-hundred-megabyte page". These
15// two numbers are what stops that. Rows paginate; columns do not,
16// because 26 of them is what a person expects to see across and a
17// second axis of paging is a lot of navigation for very little.
18const (
19 tableRowsPerPage = 25
20 tableCols = 26
21)
22
23// tableCell is one cell in the scriptless view.
24//
25// Editing carries Raw and painting carries Display, the same split the
26// grid makes — the scriptless view is not a lesser view, it is the same
27// data without the script.
28type tableCell struct {
29 R, C int
30 Ref string // A1 notation, for the label a person reads
31 Raw string
32 Display string
33 Editing bool
34 // Number is true when the COMPUTED value is numeric, which is what
35 // lets the view right-align it. Alignment is the type signal in a
36 // spreadsheet, and the grid gets it from Jspreadsheet for free while
37 // this view has to be told.
38 //
39 // It is taken from the computed kind rather than from Raw, because
40 // `=SUM(B2:B4)` is a formula whose value is a number, and sniffing
41 // the input string would align the two differently. The server owns
42 // every string on screen here; it owns this one bit too.
43 Number bool
44 // Paint is the cell's formatting, rendered the same way the grid's
45 // paint.js renders it — same attributes, same custom properties, so
46 // sheets.css styles both screens from one set of rules.
47 Paint cellPaint
48}
49
50type tableRow struct {
51 Number int // the 1-based row number a person sees
52 Cells []tableCell
53}
54
55// tableView is the page's payload.
56type tableView struct {
57 // MayWrite gates every claim and affordance only an editor can act
58 // on. handleTable admits anyone who may READ, while
59 // handleTableWrite 404s a viewer — so without it the page told
60 // every reader the cells were editable, then refused them.
61 MayWrite bool
62 Sheet SheetSummary
63 Rows []tableRow
64 Cols []string
65 Warned []string
66 View ViewSpec
67 FilterOps []string
68 // The same three lists the grid's Format menu is built from, so a
69 // control this page renders is a value the format route accepts —
70 // by construction rather than by remembering to edit both.
71 NumberFormats []numberFormat
72 FillSwatches []swatch
73 InkSwatches []swatch
74 Page int
75 Pages int
76 PrevPage int
77 NextPage int
78 HasPrev bool
79 HasNext bool
80}
81
82// handleTable renders a page of a tab as an ordinary HTML table.
83//
84// Read-only until you ask to edit one cell: ?edit=r,c turns that cell
85// into a form. Rendering an input in every cell would be 650 forms a
86// page, most of them never touched, and would make the fallback the
87// heaviest page in the app.
88func (a *App) handleTable(w http.ResponseWriter, r *http.Request) {
89 s, ok := a.sheetFromURL(w, r)
90 if !ok {
91 return
92 }
93 h, err := a.openTabFor(s)
94 if err != nil {
95 a.fail(w, r, err)
96 return
97 }
98 defer h.Release()
99
100 // The viewer may be absent: a signed-out reader can reach a sheet
101 // through a share link, and that reader can never write.
102 mayWrite := false
103 if actor, okActor := a.actor(r); okActor {
104 mayWrite = a.mayWrite(s.ID, actor)
105 }
106
107 page := intParam(r, "page")
108 if page < 1 {
109 page = 1
110 }
111 pages := (maxWindowRows + tableRowsPerPage - 1) / tableRowsPerPage
112 if page > pages {
113 page = pages
114 }
115 editR, editC := parseEdit(r.URL.Query().Get("edit"))
116 if !mayWrite {
117 // Read-only expressed as a coordinate no cell can equal, which
118 // is the same shape the anonymous link view uses. A viewer or
119 // commenter who types ?edit=0,0 by hand gets the plain table
120 // rather than a form that would 404 on submit, and the template
121 // is not trusted to remember the rule.
122 editR, editC = -1, -1
123 }
124
125 first := (page - 1) * tableRowsPerPage
126
127 view := tableView{
128 Sheet: s, Page: page, Pages: pages,
129 PrevPage: page - 1, NextPage: page + 1,
130 HasPrev: page > 1, HasNext: page < pages,
131 MayWrite: mayWrite,
132 // Ranged over in the template, exactly as the grid's Format
133 // menu ranges over them, so a control this page offers is a
134 // value handleFormatPost's allowlist accepts.
135 NumberFormats: numberFormats,
136 FillSwatches: fillSwatches,
137 InkSwatches: inkSwatches,
138 }
139 for c := 0; c < tableCols; c++ {
140 view.Cols = append(view.Cols, colLabel(c))
141 }
142 spec := a.loadView(s.TabID)
143 view.View = spec
144 view.FilterOps = filterOps
145 view.Rows = a.tableRowsIn(h, applyView(h, spec, first, tableRowsPerPage), editR, editC)
146
147 view.Warned = warnedCells(h, newPosIndex(h), maxWindowRows, tableCols)
148 a.render(w, r, "table", onSheet(s, "Table view"), http.StatusOK, view)
149}
150
151// handleTableWrite is the scriptless write path.
152//
153// It goes through the same store.SetReport and the same hub as the
154// grid's JSON endpoint, so an edit made here fans out to every open
155// grid exactly like any other. That is the point of routing both
156// through one place: a fallback that wrote by a different path would
157// drift, and the drift would only show when somebody actually used it.
158func (a *App) handleTableWrite(w http.ResponseWriter, r *http.Request) {
159 s, ok := a.sheetFromURL(w, r)
160 if !ok {
161 return
162 }
163 actor, okActor := a.actor(r)
164 if !okActor {
165 a.forbidden(w, r)
166 return
167 }
168 // sheetFromURL proved the viewer may READ. Writing needs more, and
169 // a commenter or viewer reaching this route gets the same 404 as a
170 // stranger.
171 if !a.mayWrite(s.ID, actor) {
172 a.notFound(w, r)
173 return
174 }
175 rowIdx, err := strconv.Atoi(r.FormValue("r"))
176 if err != nil || rowIdx < 0 || rowIdx >= maxWindowRows {
177 a.notFound(w, r)
178 return
179 }
180 colIdx, err := strconv.Atoi(r.FormValue("c"))
181 if err != nil || colIdx < 0 || colIdx >= tableCols {
182 a.notFound(w, r)
183 return
184 }
185 raw := r.FormValue("raw")
186 if len(raw) > maxRawBytes {
187 a.badRequest(w, r, http.StatusRequestEntityTooLarge, "Cell input too long.")
188 return
189 }
190
191 h, err := a.openTabFor(s)
192 if err != nil {
193 a.fail(w, r, err)
194 return
195 }
196 defer h.Release()
197
198 rowID, err := h.EnsureRow(rowIdx)
199 if err != nil {
200 a.fail(w, r, err)
201 return
202 }
203 colID, err := h.EnsureCol(colIdx)
204 if err != nil {
205 a.fail(w, r, err)
206 return
207 }
208 rep, err := h.SetReport(rowID, colID, raw, actor.ID)
209 if err != nil {
210 a.badRequest(w, r, http.StatusBadRequest, "That cell could not be written.")
211 return
212 }
213 a.publishReport(s, h, rep)
214
215 // 303 back to the page you were on, with no ?edit — a POST that
216 // renders resubmits on reload, and the reload would rewrite the
217 // cell.
218 //
219 // The page number is parsed and re-formatted rather than escaped
220 // and passed through: it goes into a Location header, and a number
221 // that came back out as a number cannot carry anything else.
222 page, err := strconv.Atoi(r.FormValue("page"))
223 if err != nil || page < 1 {
224 page = 1
225 }
226 http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+
227 "/table?page="+strconv.Itoa(page), http.StatusSeeOther)
228}
229
230// openTabFor opens the handle for a sheet's first tab.
231func (a *App) openTabFor(s SheetSummary) (*store.Handle, error) {
232 if a.db == nil || a.sealer == nil {
233 return nil, errNoSealer
234 }
235 return store.Open(a.db, a.sealer, s.TabID)
236}
237
238// parseEdit reads "r,c" from the query. Anything malformed selects no
239// cell, which renders the plain read-only table — the safe default.
240func parseEdit(v string) (int, int) {
241 parts := strings.SplitN(v, ",", 2)
242 if len(parts) != 2 {
243 return -1, -1
244 }
245 r, err1 := strconv.Atoi(parts[0])
246 c, err2 := strconv.Atoi(parts[1])
247 if err1 != nil || err2 != nil {
248 return -1, -1
249 }
250 return r, c
251}
252
253// colLabel turns a zero-based column index into spreadsheet letters:
254// 0 -> A, 25 -> Z, 26 -> AA.
255func colLabel(n int) string {
256 label := ""
257 for n >= 0 {
258 label = string(rune('A'+n%26)) + label
259 n = n/26 - 1
260 }
261 return label
262}
263