oficina / sheets Public

feat: a scriptless table view that can edit any cell

Spec 6's other half: the grid editor, drag-fill and live sync are
script-required, and everything else is not. This is the everything
else — read any cell, edit any cell, paginated because a 200,000-cell
tab rendered whole is a several-hundred-megabyte page.

One cell at a time carries a form, reached by a link. An input in all
650 cells of a page would make the fallback the heaviest page in the
app, and almost none of them would ever be touched.

Both write paths go through one store call and one hub publish, so an
edit made with JavaScript off fans out to every open grid like any
other. A fallback that wrote by its own path would drift, and the drift
would only show when somebody actually used it — the test asserts the
fan-out rather than trusting the shape.

The affordance gate strips every script tag and inline handler from
each page and asserts the actions are still there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
Paul Campbell pushed by paul@keymail.dev 4cb80dba0dd85039645b1a4341fd57185c57bd49 parent f7f63e9
4 files changed, +500 −0
  • internal/sheets/app.go +5 −0
  • internal/sheets/handlers_table.go +225 −0
  • internal/sheets/handlers_table_test.go +206 −0
  • internal/sheets/templates/table.html +64 −0
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index 61c1d8b..af3e4b4 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -130,6 +130,11 @@ func (a *App) Router() http.Handler {
r.Get("/sheets", a.handleSheetList)
r.Post("/sheets", a.handleSheetCreate)
r.Get("/sheets/{sheetID}", a.handleSheetPage)
+ // The scriptless view. Spec §6 names the grid editor, drag-fill
+ // and live sync as the only script-required things; everything
+ // else, including editing any cell, works here.
+ r.Get("/sheets/{sheetID}/table", a.handleTable)
+ r.Post("/sheets/{sheetID}/table", a.handleTableWrite)
// The grid's own API. Under /api because it answers JSON and
// carries no chrome; behind the same session as everything
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go
new file mode 100644
index 0000000..50acb52
--- /dev/null
+++ b/internal/sheets/handlers_table.go
@@ -0,0 +1,225 @@
+package sheets
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// The scriptless table's page size.
+//
+// Spec §6: the table view is paginated, "because a 200,000-cell tab
+// rendered as one HTML table is a several-hundred-megabyte page". These
+// two numbers are what stops that. Rows paginate; columns do not,
+// because 26 of them is what a person expects to see across and a
+// second axis of paging is a lot of navigation for very little.
+const (
+ tableRowsPerPage = 25
+ tableCols = 26
+)
+
+// tableCell is one cell in the scriptless view.
+//
+// Editing carries Raw and painting carries Display, the same split the
+// grid makes — the scriptless view is not a lesser view, it is the same
+// data without the script.
+type tableCell struct {
+ R, C int
+ Ref string // A1 notation, for the label a person reads
+ Raw string
+ Display string
+ Editing bool
+}
+
+type tableRow struct {
+ Number int // the 1-based row number a person sees
+ Cells []tableCell
+}
+
+// tableView is the page's payload.
+type tableView struct {
+ Sheet SheetSummary
+ Rows []tableRow
+ Cols []string
+ Page int
+ Pages int
+ PrevPage int
+ NextPage int
+ HasPrev bool
+ HasNext bool
+}
+
+// handleTable renders a page of a tab as an ordinary HTML table.
+//
+// Read-only until you ask to edit one cell: ?edit=r,c turns that cell
+// into a form. Rendering an input in every cell would be 650 forms a
+// page, most of them never touched, and would make the fallback the
+// heaviest page in the app.
+func (a *App) handleTable(w http.ResponseWriter, r *http.Request) {
+ s, ok := a.sheetFromURL(w, r)
+ if !ok {
+ return
+ }
+ h, err := a.openTabFor(s)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ defer h.Release()
+
+ page := intParam(r, "page")
+ if page < 1 {
+ page = 1
+ }
+ pages := (maxWindowRows + tableRowsPerPage - 1) / tableRowsPerPage
+ if page > pages {
+ page = pages
+ }
+ editR, editC := parseEdit(r.URL.Query().Get("edit"))
+
+ idx := newPosIndex(h)
+ first := (page - 1) * tableRowsPerPage
+
+ view := tableView{
+ Sheet: s, Page: page, Pages: pages,
+ PrevPage: page - 1, NextPage: page + 1,
+ HasPrev: page > 1, HasNext: page < pages,
+ }
+ for c := 0; c < tableCols; c++ {
+ view.Cols = append(view.Cols, colLabel(c))
+ }
+ for i := 0; i < tableRowsPerPage; i++ {
+ rowIdx := first + i
+ row := tableRow{Number: rowIdx + 1}
+ for c := 0; c < tableCols; c++ {
+ cell := tableCell{
+ R: rowIdx, C: c,
+ Ref: colLabel(c) + strconv.Itoa(rowIdx+1),
+ Editing: rowIdx == editR && c == editC,
+ }
+ if rowID, okR := idx.rowID[rowIdx]; okR {
+ if colID, okC := idx.colID[c]; okC {
+ cell.Raw, _ = h.RawInput(rowID, colID)
+ cell.Display = display(h.Value(rowID, colID), h.Format(rowID, colID))
+ }
+ }
+ row.Cells = append(row.Cells, cell)
+ }
+ view.Rows = append(view.Rows, row)
+ }
+
+ a.render(w, r, "table", s.Title+" — table view", http.StatusOK, view)
+}
+
+// handleTableWrite is the scriptless write path.
+//
+// It goes through the same store.SetReport and the same hub as the
+// grid's JSON endpoint, so an edit made here fans out to every open
+// grid exactly like any other. That is the point of routing both
+// through one place: a fallback that wrote by a different path would
+// drift, and the drift would only show when somebody actually used it.
+func (a *App) handleTableWrite(w http.ResponseWriter, r *http.Request) {
+ s, ok := a.sheetFromURL(w, r)
+ if !ok {
+ return
+ }
+ actor, okActor := a.actor(r)
+ if !okActor {
+ http.Error(w, "Sign in first.", http.StatusForbidden)
+ return
+ }
+ rowIdx, err := strconv.Atoi(r.FormValue("r"))
+ if err != nil || rowIdx < 0 || rowIdx >= maxWindowRows {
+ a.notFound(w, r)
+ return
+ }
+ colIdx, err := strconv.Atoi(r.FormValue("c"))
+ if err != nil || colIdx < 0 || colIdx >= tableCols {
+ a.notFound(w, r)
+ return
+ }
+ raw := r.FormValue("raw")
+ if len(raw) > maxRawBytes {
+ http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge)
+ return
+ }
+
+ h, err := a.openTabFor(s)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ defer h.Release()
+
+ rowID, err := h.EnsureRow(rowIdx)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ colID, err := h.EnsureCol(colIdx)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ rep, err := h.SetReport(rowID, colID, raw, actor.ID)
+ if err != nil {
+ http.Error(w, "That cell could not be written.", http.StatusBadRequest)
+ return
+ }
+ a.touchSheet(s.ID)
+ a.publish(s.TabID, gridResponse{
+ Version: rep.Version,
+ Cells: fromReport(rep, newPosIndex(h), h),
+ Resync: rep.Capped,
+ })
+
+ // 303 back to the page you were on, with no ?edit — a POST that
+ // renders resubmits on reload, and the reload would rewrite the
+ // cell.
+ //
+ // The page number is parsed and re-formatted rather than escaped
+ // and passed through: it goes into a Location header, and a number
+ // that came back out as a number cannot carry anything else.
+ page, err := strconv.Atoi(r.FormValue("page"))
+ if err != nil || page < 1 {
+ page = 1
+ }
+ http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+
+ "/table?page="+strconv.Itoa(page), http.StatusSeeOther)
+}
+
+// openTabFor opens the handle for a sheet's first tab.
+func (a *App) openTabFor(s SheetSummary) (*store.Handle, error) {
+ if a.db == nil || a.sealer == nil {
+ return nil, errNoSealer
+ }
+ return store.Open(a.db, a.sealer, s.TabID)
+}
+
+// parseEdit reads "r,c" from the query. Anything malformed selects no
+// cell, which renders the plain read-only table — the safe default.
+func parseEdit(v string) (int, int) {
+ parts := strings.SplitN(v, ",", 2)
+ if len(parts) != 2 {
+ return -1, -1
+ }
+ r, err1 := strconv.Atoi(parts[0])
+ c, err2 := strconv.Atoi(parts[1])
+ if err1 != nil || err2 != nil {
+ return -1, -1
+ }
+ return r, c
+}
+
+// colLabel turns a zero-based column index into spreadsheet letters:
+// 0 -> A, 25 -> Z, 26 -> AA.
+func colLabel(n int) string {
+ label := ""
+ for n >= 0 {
+ label = string(rune('A'+n%26)) + label
+ n = n/26 - 1
+ }
+ return label
+}
diff --git a/internal/sheets/handlers_table_test.go b/internal/sheets/handlers_table_test.go
new file mode 100644
index 0000000..61d06d6
--- /dev/null
+++ b/internal/sheets/handlers_table_test.go
@@ -0,0 +1,206 @@
+package sheets
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// scriptTag and eventAttr strip a page down to what works with
+// JavaScript switched off.
+var (
+ scriptTag = regexp.MustCompile(`(?is)<script\b.*?</script>`)
+ eventAttr = regexp.MustCompile(`(?i)\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)`)
+)
+
+func withoutScript(html string) string {
+ return eventAttr.ReplaceAllString(scriptTag.ReplaceAllString(html, ""), "")
+}
+
+// The gate spec §6 asks for, made concrete: every write the grid can
+// do, the scriptless view can do too — proven by actually doing it,
+// with no JavaScript anywhere in the request.
+func TestACellCanBeEditedWithNoScriptAtAll(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ table := fmt.Sprintf("/sheets/%d/table", s.ID)
+
+ form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=2*21"}, "page": {"1"}}
+ rec := do(t, app, as(postForm(table, form), cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("scriptless cell edit = %d, want 303: %s", rec.Code, rec.Body.String())
+ }
+
+ page := do(t, app, as(httptest.NewRequest(http.MethodGet, table, nil), cookies)).Body.String()
+ if !strings.Contains(withoutScript(page), ">42<") {
+ t.Fatal("the scriptless table does not show the value it just computed")
+ }
+}
+
+// The two views must be one product. An edit made in either is visible
+// in the other, because both go through the same store call.
+func TestAScriptlessEditIsVisibleInTheGridAPI(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+
+ form := url.Values{"r": {"3"}, "c": {"2"}, "raw": {"typed here"}, "page": {"1"}}
+ do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form), cookies))
+
+ got := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ var found bool
+ for _, c := range got.Cells {
+ if c.R == 3 && c.C == 2 && c.Display == "typed here" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("the grid API does not see the scriptless edit: %+v", got.Cells)
+ }
+}
+
+// A scriptless edit must fan out like any other, or somebody using the
+// fallback is invisible to everybody watching the grid.
+func TestAScriptlessEditReachesOpenGrids(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ sub := app.hub.Subscribe(s.TabID)
+ defer sub.Close()
+
+ form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=3*3"}, "page": {"1"}}
+ do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form), cookies))
+
+ select {
+ case m := <-sub.C():
+ var g gridResponse
+ if err := json.Unmarshal(m.Data, &g); err != nil {
+ t.Fatalf("payload does not decode: %v", err)
+ }
+ if len(g.Cells) != 1 || g.Cells[0].Display != "9" {
+ t.Fatalf("fanned out %+v, want one cell displaying 9", g.Cells)
+ }
+ default:
+ t.Fatal("a scriptless edit reached no open grid")
+ }
+}
+
+// Spec §6: paginated, "because a 200,000-cell tab rendered as one HTML
+// table is a several-hundred-megabyte page".
+func TestTableViewIsPaginated(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ table := fmt.Sprintf("/sheets/%d/table", s.ID)
+
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, table, nil), cookies)).Body.String()
+ if n := strings.Count(body, "<tr>"); n > tableRowsPerPage+2 {
+ t.Fatalf("the page renders %d rows, want at most %d plus a header",
+ n, tableRowsPerPage)
+ }
+ if !strings.Contains(body, "Next rows") {
+ t.Error("no way to reach the rows below this page")
+ }
+
+ second := do(t, app, as(httptest.NewRequest(http.MethodGet, table+"?page=2", nil), cookies))
+ if second.Code != http.StatusOK {
+ t.Fatalf("page 2 = %d", second.Code)
+ }
+ // Row 26 is the first row of page two. If both pages showed row 1,
+ // the pagination would be decoration.
+ if !strings.Contains(second.Body.String(), ">26<") {
+ t.Error("page 2 does not start where page 1 ended")
+ }
+}
+
+// The affordance gate. Every page must still offer its actions with
+// every script and inline handler stripped out.
+func TestEveryPageWorksWithoutScript(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ pages := []struct {
+ path string
+ want []string
+ }{
+ {"/", []string{`action="/signout"`}},
+ {"/sheets", []string{`action="/sheets"`, `name="title"`}},
+ {fmt.Sprintf("/sheets/%d", s.ID), []string{fmt.Sprintf("/sheets/%d/table", s.ID)}},
+ {fmt.Sprintf("/sheets/%d/table", s.ID), []string{"?page=1&amp;edit=0,0", "Next rows"}},
+ {fmt.Sprintf("/sheets/%d/table?page=1&edit=0,0", s.ID),
+ []string{`method="post"`, `name="raw"`, "Save"}},
+ }
+ for _, p := range pages {
+ body := withoutScript(do(t, app,
+ as(httptest.NewRequest(http.MethodGet, p.path, nil), cookies)).Body.String())
+ if strings.Contains(strings.ToLower(body), "<script") {
+ t.Errorf("%s: a script survived stripping", p.path)
+ }
+ for _, want := range p.want {
+ if !strings.Contains(body, want) {
+ t.Errorf("%s: with scripts stripped, %q is gone — that affordance is script-required",
+ p.path, want)
+ }
+ }
+ }
+}
+
+// The edit form must be pre-filled with the cell's INPUT, not its
+// result, or editing a formula silently replaces it with its own
+// output.
+func TestTheEditFormShowsTheFormulaNotTheResult(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ table := fmt.Sprintf("/sheets/%d/table", s.ID)
+
+ do(t, app, as(postForm(table, url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=8*8"}, "page": {"1"}}), cookies))
+
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, table+"?page=1&edit=0,0", nil), cookies)).Body.String()
+ if !strings.Contains(body, `value="=8*8"`) {
+ t.Fatalf("the edit form is not pre-filled with the formula:\n%s",
+ body[max(0, strings.Index(body, "name=\"raw\"")-200):])
+ }
+}
+
+func TestTableViewOfAMissingSheetIs404(t *testing.T) {
+ app, cookies := signIn(t)
+ rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets/999999/table", nil), cookies))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("table view of a missing sheet = %d, want 404", rec.Code)
+ }
+}
+
+func TestTableWriteRequiresASession(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"sneaky"}, "page": {"1"}}
+ rec := do(t, app, postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form))
+ if rec.Code == http.StatusSeeOther {
+ t.Fatal("a scriptless write with no session was accepted")
+ }
+}
+
+func TestColLabel(t *testing.T) {
+ for _, tc := range []struct {
+ n int
+ want string
+ }{{0, "A"}, {1, "B"}, {25, "Z"}, {26, "AA"}, {27, "AB"}, {51, "AZ"}, {52, "BA"}} {
+ if got := colLabel(tc.n); got != tc.want {
+ t.Errorf("colLabel(%d) = %q, want %q", tc.n, got, tc.want)
+ }
+ }
+}
+
+func TestParseEditRejectsRubbish(t *testing.T) {
+ for _, bad := range []string{"", "1", "a,b", "1,", ",2", "1,2,3x"} {
+ if r, c := parseEdit(bad); r != -1 || c != -1 {
+ t.Errorf("parseEdit(%q) = %d,%d, want no cell selected", bad, r, c)
+ }
+ }
+ if r, c := parseEdit("3,4"); r != 3 || c != 4 {
+ t.Errorf("parseEdit(\"3,4\") = %d,%d", r, c)
+ }
+}
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html
new file mode 100644
index 0000000..0bade45
--- /dev/null
+++ b/internal/sheets/templates/table.html
@@ -0,0 +1,64 @@
+{{define "content"}}
+<header rst-page-header>
+<div rst-page-header-titles>
+<h1>{{.Data.Sheet.Title}}</h1>
+<p rst-page-header-sub>Table view — rows {{.Data.Page}} of {{.Data.Pages}} pages.
+Every cell is editable here, with no JavaScript at all.</p>
+</div>
+<a rst-btn="primary" href="/sheets/{{.Data.Sheet.ID}}">Open the grid</a>
+</header>
+
+<div style="overflow-x:auto">
+<table data-cell-table>
+<caption class="rst-sr-only">{{.Data.Sheet.Title}}, page {{.Data.Page}} of {{.Data.Pages}}</caption>
+<thead>
+<tr>
+<th scope="col"><span class="rst-sr-only">Row</span></th>
+{{range .Data.Cols}}<th scope="col">{{.}}</th>{{end}}
+</tr>
+</thead>
+<tbody>
+{{$sheet := .Data.Sheet}}
+{{$page := .Data.Page}}
+{{range .Data.Rows}}
+<tr>
+<th scope="row">{{.Number}}</th>
+{{range .Cells}}
+<td>
+{{if .Editing}}
+{{/* One cell at a time carries a form. Rendering an input in all 650
+ cells of a page would make the scriptless view the heaviest page
+ in the app, and most of those inputs would never be touched. */}}
+<form method="post" action="/sheets/{{$sheet.ID}}/table">
+<input type="hidden" name="r" value="{{.R}}">
+<input type="hidden" name="c" value="{{.C}}">
+<input type="hidden" name="page" value="{{$page}}">
+<label class="rst-sr-only" for="cell-{{.Ref}}">{{.Ref}}</label>
+<input rst-input id="cell-{{.Ref}}" name="raw" type="text" value="{{.Raw}}" autofocus>
+<button rst-btn="primary compact" type="submit">Save</button>
+</form>
+{{else}}
+{{/* The link is the affordance: it turns this cell into the form
+ above. The cell shows its computed value, and the title carries
+ the input, so a formula is readable without opening it. */}}
+<a href="/sheets/{{$sheet.ID}}/table?page={{$page}}&amp;edit={{.R}},{{.C}}"
+ aria-label="Edit {{.Ref}}"{{if .Raw}} title="{{.Raw}}"{{end}}>{{if .Display}}{{.Display}}{{else}}&nbsp;{{end}}</a>
+{{end}}
+</td>
+{{end}}
+</tr>
+{{end}}
+</tbody>
+</table>
+</div>
+
+<nav rst-pagination aria-label="Rows">
+{{if .Data.HasPrev}}
+<a href="/sheets/{{.Data.Sheet.ID}}/table?page={{.Data.PrevPage}}">Previous rows</a>
+{{else}}<span rst-pagination-disabled>Previous rows</span>{{end}}
+<span>Page {{.Data.Page}} of {{.Data.Pages}}</span>
+{{if .Data.HasNext}}
+<a href="/sheets/{{.Data.Sheet.ID}}/table?page={{.Data.NextPage}}">Next rows</a>
+{{else}}<span rst-pagination-disabled>Next rows</span>{{end}}
+</nav>
+{{end}}