| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | type 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. |
| 30 | type 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. |
| 39 | type 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. |
| 82 | func (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. |
| 94 | func (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. |
| 144 | func (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. |
| 167 | func (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. |
| 225 | const 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. |
| 239 | func 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. |
| 256 | func (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 | |