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 "errors"
5 "html/template"
6 "net/http"
7 "strconv"
8
9 "amadan.net/oficina/estilo.git/chrome"
10 estilomenu "amadan.net/oficina/estilo.git/menu"
11 "github.com/go-chi/chi/v5"
12 "gorm.io/gorm"
13)
14
15// sheetRow is one line of the sheet list: a summary plus its
16// thumbnail, rendered here rather than in the template because the
17// template cannot call a method that needs the app.
18type sheetRow struct {
19 SheetSummary
20 Preview template.HTML
21}
22
23// sheetListView is the sheet list's payload.
24//
25// It carried an ImportError until the import form moved off this page
26// to /sheets/import. The reasoning for keeping a refusal on the same
27// page as its form was right and still is — it needs no flash, no
28// cookie and no redirect — which is why the field moved with the form
29// rather than being dropped: it is importPageView.Error now.
30type sheetListView struct {
31 Sheets []sheetRow
32}
33
34// sheetPageView is the grid page's payload.
35//
36// Rows and Cols are the window's dimensions, sent to the client so the
37// grid builds the right shape before it has fetched a single cell —
38// otherwise the page reflows the moment the first response lands.
39type sheetPageView struct {
40 Sheet SheetSummary
41 Rows int
42 Cols int
43 // Truncated is set when the sheet reaches past the widest window
44 // the grid can paint. It is rendered as an attribute the page can
45 // read rather than as prose in Go, so the wording lives with the
46 // rest of the copy.
47 Truncated bool
48 // MayShare drives whether the page offers a Share link. The route
49 // checks again — this only decides what to render, and a button
50 // that 404s is worse than no button.
51 MayShare bool
52 // MayWrite decides whether the editing chrome is rendered at all.
53 //
54 // Without it a viewer gets a toolbar and a Format menu whose every
55 // control 404s — the route checks again, correctly, and the person
56 // is left pressing buttons that silently do nothing. A control you
57 // are not allowed to use should be absent, not broken.
58 MayWrite bool
59 // MayComment decides whether Insert offers the comment link. A
60 // commenter may not edit a cell but may say something about one,
61 // which is a different question from MayWrite and has to be asked
62 // separately.
63 MayComment bool
64 // FillSwatches and InkSwatches are the palettes the toolbar's two
65 // colour menus render. They come from the page view rather than a
66 // template func so there is exactly one list, defined in Go, that
67 // both the buttons and the write endpoint's validation read.
68 FillSwatches []swatch
69 InkSwatches []swatch
70 // NumberFormats is the same list the write endpoint validates
71 // against, so a button the UI renders is a pattern the route
72 // accepts — by construction rather than by both being edited.
73 NumberFormats []numberFormat
74 // Warned lists cells whose IMPORTED fill and ink fail WCAG AA.
75 // Spec §11.1 ruled that such a pair is rendered as authored and
76 // that the obligation is surfacing, not overriding — so this is a
77 // notice on the page, never a correction to the cell.
78 Warned []string
79}
80
81// handleSheetList renders the sheets the viewer may see.
82func (a *App) handleSheetList(w http.ResponseWriter, r *http.Request) {
83 a.renderSheetList(w, r, http.StatusOK)
84}
85
86// renderSheetList draws the list.
87//
88// It used to take a refused upload's explanation and a status to go
89// with it, because the import form lived on this page and a refusal
90// belongs beside the form that caused it. The form moved to
91// /sheets/import and the refusal went with it — renderImportPage keeps
92// both the parameter and the reasoning. What is left here has one
93// caller and one status, so it no longer takes either.
94func (a *App) renderSheetList(w http.ResponseWriter, r *http.Request, status int) {
95 viewer, ok := a.actor(r)
96 if !ok {
97 a.forbidden(w, r)
98 return
99 }
100 list, err := a.listSheets(viewer)
101 if err != nil {
102 a.fail(w, r, err)
103 return
104 }
105 if wantsNative(r) {
106 // Reuse the web preview's shape-only query; never decrypt cells or
107 // return HTML to a native renderer.
108 type nativeSheetRow struct {
109 SheetSummary
110 Preview preview
111 }
112 rows := make([]nativeSheetRow, 0, len(list))
113 for _, sheet := range list {
114 rows = append(rows, nativeSheetRow{SheetSummary: sheet, Preview: a.buildPreview(sheet.TabID)})
115 }
116 nativeJSON(w, status, map[string]any{"Sheets": rows})
117 return
118 }
119 rows := make([]sheetRow, 0, len(list))
120 for _, s := range list {
121 rows = append(rows, sheetRow{SheetSummary: s, Preview: a.previewFor(s)})
122 }
123 // Import and Create are the list's page actions: buttons in the bar
124 // at wide widths, rows in its Menu sheet below 800px. Create is a
125 // POST in both places (estilo's chrome.Action): it makes a sheet
126 // called "Untitled sheet" in one click and 303s into it.
127 f := frame{actions: []chrome.Action{
128 {Item: estilomenu.Item{Href: "/sheets/import", Label: "Import"}},
129 {Item: estilomenu.Item{Action: "/sheets", Label: "Create"}, Primary: true},
130 }}
131 a.render(w, r, "sheets", f, status, sheetListView{Sheets: rows})
132}
133
134// handleSheetCreate makes a sheet and sends you to it.
135//
136// The title is read field by field with r.FormValue into an explicit
137// struct — never bound onto a GORM model (AGENTS.md / Mechanical
138// rules), which would let a form field set CreatedBy, or DeletedAt, or
139// an id.
140//
141// It answers 303 rather than rendering: a POST that renders is a POST
142// that resubmits on reload, and the second submission makes a second
143// sheet.
144func (a *App) handleSheetCreate(w http.ResponseWriter, r *http.Request) {
145 actor, ok := a.actor(r)
146 if !ok {
147 a.forbidden(w, r)
148 return
149 }
150 title := r.FormValue("title")
151 if title == "" {
152 title = "Untitled sheet"
153 }
154 s, err := a.createSheet(title, actor)
155 if err != nil {
156 a.fail(w, r, err)
157 return
158 }
159 if wantsNative(r) {
160 nativeJSON(w, 201, s)
161 return
162 }
163 http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10), http.StatusSeeOther)
164}
165
166// handleSheetPage renders the grid.
167func (a *App) handleSheetPage(w http.ResponseWriter, r *http.Request) {
168 s, ok := a.sheetFromURL(w, r)
169 if !ok {
170 return
171 }
172 view := sheetPageView{
173 Sheet: s, Rows: defaultWindowRows, Cols: defaultWindowCols,
174 FillSwatches: fillSwatches, InkSwatches: inkSwatches,
175 NumberFormats: numberFormats,
176 }
177 if viewer, ok := a.actor(r); ok {
178 view.MayShare = a.mayShare(s.ID, viewer)
179 view.MayWrite = a.mayWrite(s.ID, viewer)
180 view.MayComment = a.mayComment(s.ID, viewer)
181 }
182 // Best effort: a sheet whose handle will not open still renders its
183 // page, and the grid's own fetch reports the failure properly.
184 if h, err := a.openTabFor(s); err == nil {
185 idx := newPosIndex(h)
186 // The window is sized to the sheet rather than to a constant.
187 // clamp's floor keeps a one-cell sheet opening a grid somebody
188 // can type into, and its ceiling is what the cells route would
189 // clamp the request to anyway — asking for more than the server
190 // will serve renders empty rows the grid can scroll to and never
191 // fill, which is a different lie in the same place.
192 eRows, eCols := extent(h, idx)
193 view.Rows = windowFor(eRows, defaultWindowRows, maxWindowRows)
194 view.Cols = windowFor(eCols, defaultWindowCols, maxWindowCols)
195 // Past the ceiling the honest answer is words, not a wider grid.
196 // The page says so and clipboard.js's status line reads it out;
197 // silently painting the first 500 rows of a longer sheet is the
198 // same class of quiet as the constant window was.
199 view.Truncated = eRows > maxWindowRows || eCols > maxWindowCols
200 view.Warned = warnedCells(h, idx, view.Rows, view.Cols)
201 h.Release()
202 }
203 f := onSheet(s, "")
204 f.fill = true
205 if view.MayWrite {
206 // The rename is the bar's editable title (estilo's TitleEdit),
207 // posting to the same route it always has.
208 f.edit = &chrome.TitleEdit{
209 Action: "/sheets/" + strconv.FormatUint(s.ID, 10) + "/rename",
210 Name: "title", Value: s.Title,
211 Label: "Sheet title", Submit: "Rename",
212 }
213 }
214 a.render(w, r, "sheet", f, http.StatusOK, view)
215}
216
217// windowGrowRoom is the empty space kept below and to the right of a
218// sheet's furthest cell.
219//
220// Without it the window ends exactly where the content does, and the
221// row under the last one you typed in is not on the screen — a grid you
222// cannot add a row to is a worse bug than the one this fixes. A
223// screenful is the right size: enough to keep typing into, not so much
224// that the scrollbar claims a sheet far longer than it is.
225const windowGrowRoom = 20
226
227// windowFor sizes one axis of the grid's window.
228//
229// clamp is deliberately not used, and the difference is the whole point:
230// clamp's `def` is what you get for a request of ZERO, so a sheet with a
231// single cell in A1 asked for a one-row grid and clamp handed back one
232// row. def here is a FLOOR — a nearly empty sheet still opens a grid
233// somebody can work in.
234//
235// The ceiling is what the cells route would clamp a request to anyway.
236// Opening a window wider than the server will serve paints rows that can
237// be scrolled to and never filled, which is a different lie in the same
238// place.
239func windowFor(reach, floor, ceiling int) int {
240 want := reach + windowGrowRoom
241 if want < floor {
242 want = floor
243 }
244 if want > ceiling {
245 want = ceiling
246 }
247 return want
248}
249
250// sheetFromURL reads {sheetID} and loads it, answering 404 itself if it
251// is not there. The bool is whether the caller should carry on.
252//
253// A sheet that is not there — or an id that was never a number — is a
254// 404, never a 403 (AGENTS.md). A 403 tells whoever asked that the row
255// exists, which is a fact about somebody else's data.
256func (a *App) sheetFromURL(w http.ResponseWriter, r *http.Request) (SheetSummary, bool) {
257 viewer, ok := a.actor(r)
258 if !ok {
259 a.notFound(w, r)
260 return SheetSummary{}, false
261 }
262 id, err := strconv.ParseUint(chi.URLParam(r, "sheetID"), 10, 64)
263 if err != nil || id == 0 {
264 a.notFound(w, r)
265 return SheetSummary{}, false
266 }
267 s, err := a.sheetSummary(id, viewer)
268 if err != nil {
269 // No grant and no such sheet are the same answer. See
270 // sheetSummary for why, and the two-user isolation suite for
271 // the proof that they stay the same.
272 if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errNoAccess) {
273 a.notFound(w, r)
274 return SheetSummary{}, false
275 }
276 a.fail(w, r, err)
277 return SheetSummary{}, false
278 }
279 return s, true
280}
281