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 "strings"
6 "time"
7
8 "gorm.io/gorm"
9
10 "amadan.net/oficina/sheets/internal/sheets/store"
11)
12
13// maxTitleRunes caps a sheet title. It is a UI-shaped limit, not a
14// storage one — the sealed column would take anything — and exists so a
15// pasted paragraph cannot become a sheet name that breaks every row of
16// the list.
17const maxTitleRunes = 200
18
19// firstTabName is what a new sheet's only tab is called. Sealed like
20// any other tab name: a tab name is sheet content (spec §2.1), and the
21// fact that this particular one is predictable does not make it exempt —
22// an exemption would be a plaintext write on the same column every
23// sealed name uses.
24const firstTabName = "Sheet 1"
25
26// The grid paints a window, never a whole tab. Spec §6's reasoning for
27// the 200,000-cell cap is that a tab rendered whole is a
28// several-hundred-megabyte page; a window is the smallest thing that
29// honours it, and it is a window rather than virtual scrolling because
30// virtualisation is a lot of client code to write before anybody has
31// typed into a single cell.
32//
33// defaultWindow* is what a page opens with. maxWindow* is what a
34// request may ask for: past it the numbers are clamped, not refused,
35// because refusing turns a too-wide window into a broken page while
36// clamping turns it into a slightly smaller one.
37const (
38 defaultWindowRows = 100
39 defaultWindowCols = 26 // A..Z, which is what a person expects to see
40 maxWindowRows = 500
41 maxWindowCols = 60
42)
43
44// errNoSealer is returned rather than panicking when an App built
45// without a sealer (page-rendering tests) reaches a path that needs one.
46var errNoSealer = errors.New("sheets: no sealer configured")
47
48// SheetSummary is one row of the sheet list: what a list row or a page
49// header needs, with the title already unsealed.
50//
51// TabID is the sheet's FIRST tab, which is the one a bare /sheets/{id}
52// opens. Carrying it here means the grid page never has to query for it,
53// and multi-tab navigation later adds a field rather than a round trip.
54type SheetSummary struct {
55 ID uint64
56 Title string
57 TabID uint64
58 // TabName is the first tab's name, for the tab strip along the
59 // bottom of the grid. Sealed at rest like every other piece of
60 // authored text, and unsealed here beside the title so the page has
61 // one place that opens a sheet's names rather than two.
62 //
63 // A tab whose name will not unseal falls back to "Sheet 1" rather
64 // than failing the page: the strip is chrome, and a grid nobody can
65 // open because its tab has an unreadable name is a worse outcome
66 // than a tab labelled generically.
67 TabName string
68 UpdatedAt time.Time
69}
70
71// createSheet makes a sheet and its first tab in one transaction.
72//
73// One transaction because a sheet with no tab is not openable: it would
74// appear in the list and then 404 when clicked. Nothing outside this
75// function ever creates a Sheet row, which is what makes "every sheet
76// has at least one tab" a real invariant rather than a convention
77// everyone remembers.
78// createSheet makes an ordinary, immediately visible sheet.
79func (a *App) createSheet(title string, by Member) (SheetSummary, error) {
80 return a.newSheet(title, by, false)
81}
82
83// createFillingSheet makes a sheet that is HIDDEN until something
84// finishes filling it — import's shape (store.Sheet.FillingSince).
85//
86// The caller owns the other half: markFilled on success, discardSheet
87// on failure. A caller that does neither leaves an invisible row, which
88// is the failure this is designed to have.
89func (a *App) createFillingSheet(title string, by Member) (SheetSummary, error) {
90 return a.newSheet(title, by, true)
91}
92
93func (a *App) newSheet(title string, by Member, filling bool) (SheetSummary, error) {
94 if a.db == nil {
95 return SheetSummary{}, errNoDB
96 }
97 if a.sealer == nil {
98 return SheetSummary{}, errNoSealer
99 }
100 clean := strings.TrimSpace(title)
101 if clean == "" {
102 return SheetSummary{}, errors.New("sheets: a sheet needs a title")
103 }
104 if r := []rune(clean); len(r) > maxTitleRunes {
105 clean = string(r[:maxTitleRunes])
106 }
107 sealedTitle, err := a.sealer.Seal("sheet.title", []byte(clean))
108 if err != nil {
109 return SheetSummary{}, err
110 }
111 sealedName, err := a.sealer.Seal("tab.name", []byte(firstTabName))
112 if err != nil {
113 return SheetSummary{}, err
114 }
115
116 sheet := store.Sheet{TitleSealed: sealedTitle, CreatedBy: by.ID}
117 if filling {
118 now := time.Now().UTC()
119 sheet.FillingSince = &now
120 }
121 tab := store.Tab{NameSealed: sealedName, Position: 0}
122 if err := a.db.Transaction(func(tx *gorm.DB) error {
123 if err := tx.Create(&sheet).Error; err != nil {
124 return err
125 }
126 tab.SheetID = sheet.ID
127 if err := tx.Create(&tab).Error; err != nil {
128 return err
129 }
130 // The creator owns it, written here rather than inferred from
131 // Sheet.CreatedBy. Inferring would mean two sources of truth
132 // for one question, and the share screen could not then take
133 // ownership away or hand it on.
134 return tx.Create(&Grant{
135 SheetID: sheet.ID, MemberID: by.ID,
136 Role: string(SheetOwner), GrantedBy: by.ID,
137 }).Error
138 }); err != nil {
139 return SheetSummary{}, err
140 }
141 return SheetSummary{
142 ID: sheet.ID, Title: clean, TabID: tab.ID,
143 TabName: firstTabName, UpdatedAt: sheet.UpdatedAt,
144 }, nil
145}
146
147// listSheets returns the sheets a member may see, most recently
148// touched first.
149//
150// It grew the hand-written grants join the previous comment here
151// promised — visibleSheets, grants.go — and it did NOT grow a
152// scope.Owned. scope.Owned is single-column equality on user_id, our
153// sheets are team-shared, and applied here it would return only the
154// sheets somebody created: a plausible short list rather than an error
155// (AGENTS.md / Mechanical rules).
156//
157// A team owner or member sees every sheet, which is what one instance
158// per team means. A guest sees exactly what they were granted.
159func (a *App) listSheets(viewer Member) ([]SheetSummary, error) {
160 if a.db == nil {
161 return nil, errNoDB
162 }
163 if a.sealer == nil {
164 return nil, errNoSealer
165 }
166 rows, err := a.visibleSheets(viewer)
167 if err != nil {
168 return nil, err
169 }
170 out := make([]SheetSummary, 0, len(rows))
171 for _, s := range rows {
172 sum, err := a.summarize(s)
173 if err != nil {
174 return nil, err
175 }
176 out = append(out, sum)
177 }
178 return out, nil
179}
180
181// sheetSummary reads one sheet a viewer may see.
182//
183// A sheet that is not there and a sheet they may not reach are the same
184// answer, on purpose: handlers turn both into a 404, never a 403
185// (AGENTS.md). A 403 confirms the row exists, and on an instance where
186// guests hold single-sheet grants the set of ids that 403 is a map of
187// the team's sheets.
188func (a *App) sheetSummary(id uint64, viewer Member) (SheetSummary, error) {
189 if a.db == nil {
190 return SheetSummary{}, errNoDB
191 }
192 if a.sealer == nil {
193 return SheetSummary{}, errNoSealer
194 }
195 if !a.mayRead(id, viewer) {
196 return SheetSummary{}, errNoAccess
197 }
198 var s store.Sheet
199 if err := a.db.Where("id = ? AND deleted_at IS NULL AND filling_since IS NULL", id).First(&s).Error; err != nil {
200 return SheetSummary{}, err
201 }
202 return a.summarize(s)
203}
204
205// tabBelongsTo reports whether a tab is part of a sheet.
206//
207// Every grid and stream route takes both ids from the URL, so without
208// this a caller could pair any sheet id with any tab id and read a tab
209// through a sheet it does not belong to. Once grants exist, the sheet is
210// where permission will be checked, which makes this pairing check the
211// thing that keeps the permission meaningful.
212func (a *App) tabBelongsTo(sheetID, tabID uint64) bool {
213 if a.db == nil {
214 return false
215 }
216 var n int64
217 if err := a.db.Model(&store.Tab{}).
218 Where("id = ? AND sheet_id = ?", tabID, sheetID).Count(&n).Error; err != nil {
219 return false
220 }
221 return n == 1
222}
223
224// touchSheet bumps a sheet's UpdatedAt so the list orders by real
225// activity rather than by creation. Called on every cell write; a
226// failure is not worth failing the write over, so callers ignore the
227// error and the list is merely stale.
228func (a *App) touchSheet(sheetID uint64) {
229 if a.db == nil {
230 return
231 }
232 a.db.Model(&store.Sheet{}).Where("id = ?", sheetID).
233 Update("updated_at", time.Now().UTC())
234}
235
236// summarize unseals a Sheet row's title and finds its first tab.
237func (a *App) summarize(s store.Sheet) (SheetSummary, error) {
238 title, err := a.sealer.Open("sheet.title", s.TitleSealed)
239 if err != nil {
240 return SheetSummary{}, err
241 }
242 var tab store.Tab
243 if err := a.db.Where("sheet_id = ?", s.ID).
244 Order("position ASC, id ASC").First(&tab).Error; err != nil {
245 return SheetSummary{}, err
246 }
247 return SheetSummary{
248 ID: s.ID, Title: string(title), TabID: tab.ID,
249 TabName: tabName(a, tab), UpdatedAt: s.UpdatedAt,
250 }, nil
251}
252
253// tabName unseals a tab's name, falling back to a generic label.
254//
255// The fallback is for a name that will not open — a row written under a
256// key this process does not have. It is not for an empty NameSealed:
257// newSheet has always sealed firstTabName, so no tab this app has
258// made lacks one. Either way the answer is the same and it is the
259// right one: the tab strip is chrome, and a grid nobody can open
260// because its tab's name is unreadable is a worse outcome than a tab
261// labelled generically.
262func tabName(a *App, tab store.Tab) string {
263 if len(tab.NameSealed) == 0 {
264 return "Sheet 1"
265 }
266 name, err := a.sealer.Open("tab.name", tab.NameSealed)
267 if err != nil || len(name) == 0 {
268 return "Sheet 1"
269 }
270 return string(name)
271}
272