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 "time"
7
8 "amadan.net/oficina/sheets/internal/sheets/store"
9)
10
11// historyEntry is one edit, ready to render.
12type historyEntry struct {
13 ID uint64
14 Ref string // A1 notation
15 Who string
16 Old string
17 New string
18 When time.Time
19 Mine bool
20 Cleared bool // the edit emptied the cell
21}
22
23type historyView struct {
24 Sheet SheetSummary
25 Entries []historyEntry
26 MayWrite bool
27 Notice string
28 NoticeTone string `json:"-"`
29}
30
31// handleHistory shows what has been typed into a sheet.
32//
33// Spec §4.3: the log holds inputs, never derived values. So this lists
34// edits somebody made, not cells that changed — an edit to one cell
35// that recalculated four hundred appears once, which is both what the
36// storage can support and what a person actually wants to read.
37func (a *App) handleHistory(w http.ResponseWriter, r *http.Request) {
38 s, ok := a.sheetFromURL(w, r)
39 if !ok {
40 return
41 }
42 viewer, ok := a.actor(r)
43 if !ok {
44 a.notFound(w, r)
45 return
46 }
47
48 h, err := a.openTabFor(s)
49 if err != nil {
50 a.fail(w, r, err)
51 return
52 }
53 defer h.Release()
54
55 records, err := store.History(a.db, a.sealer, store.HistoryQuery{TabID: s.TabID})
56 if err != nil {
57 a.fail(w, r, err)
58 return
59 }
60
61 idx := newPosIndex(h)
62 names := a.memberNames()
63 entries := make([]historyEntry, 0, len(records))
64 for _, rec := range records {
65 row, okR := idx.rowPos[rec.RowID]
66 col, okC := idx.colPos[rec.ColID]
67 if !okR || !okC {
68 continue // an event pointing at a row or column that is gone
69 }
70 entries = append(entries, historyEntry{
71 ID: rec.ID,
72 Ref: colLabel(col) + strconv.Itoa(row+1),
73 Who: names[rec.ActorID],
74 Old: rec.OldRaw,
75 New: rec.NewRaw,
76 When: time.Unix(rec.CreatedAt, 0).UTC(),
77 Mine: rec.ActorID == viewer.ID,
78 Cleared: rec.NewRaw == "",
79 })
80 }
81
82 a.render(w, r, "history", onSheet(s, "Version history"), http.StatusOK, historyView{
83 Sheet: s, Entries: entries,
84 MayWrite: a.mayWrite(s.ID, viewer),
85 Notice: historyNotice(r.URL.Query().Get("notice")),
86 NoticeTone: noticeTone(r.URL.Query().Get("notice")),
87 })
88}
89
90func historyNotice(code string) string {
91 switch code {
92 case "restored":
93 return "Put back. That is itself an edit, so you can undo it too."
94 case "undone":
95 return "Undone. Your last edit has been put back to what it was."
96 case "nothing":
97 return "You have not edited anything on this sheet yet."
98 case "gone":
99 return "That edit is no longer in the history."
100 default:
101 return ""
102 }
103}
104
105// memberNames maps actor ids to addresses for display.
106//
107// One query rather than one per row: a page of two hundred edits by
108// three people would otherwise be two hundred lookups.
109func (a *App) memberNames() map[uint64]string {
110 out := map[uint64]string{}
111 if a.db == nil {
112 return out
113 }
114 var members []Member
115 if err := a.db.Find(&members).Error; err != nil {
116 return out
117 }
118 for _, m := range members {
119 out[m.ID] = m.Address
120 }
121 return out
122}
123
124// handleRestore puts one cell back to what it held before an edit.
125//
126// Spec ruling 17's named baseline: view history and restore a cell or a
127// range. Restoring is a FRESH last-write-wins write, not a rewind — so
128// it appears in history as an edit by whoever did it, fans out to every
129// open tab like any other, and is itself undoable. A rewind that
130// rewrote the past would make the log disagree with the sheet.
131func (a *App) handleRestore(w http.ResponseWriter, r *http.Request) {
132 s, ok := a.sheetFromURL(w, r)
133 if !ok {
134 return
135 }
136 viewer, ok := a.actor(r)
137 if !ok {
138 a.notFound(w, r)
139 return
140 }
141 if !a.mayWrite(s.ID, viewer) {
142 a.notFound(w, r)
143 return
144 }
145 eventID, err := strconv.ParseUint(r.FormValue("event"), 10, 64)
146 if err != nil || eventID == 0 {
147 a.notFound(w, r)
148 return
149 }
150 rec, found := store.EventByID(a.db, a.sealer, s.TabID, eventID)
151 if !found {
152 a.historyRedirect(w, r, s, "gone")
153 return
154 }
155 if err := a.applyRaw(s, rec.RowID, rec.ColID, rec.OldRaw, viewer); err != nil {
156 a.fail(w, r, err)
157 return
158 }
159 a.historyRedirect(w, r, s, "restored")
160}
161
162// undoScan is how far back undo will look for an edit it can read.
163//
164// It asked for one row and took what came back, which is wrong because
165// History skips an event it cannot unseal: one unreadable newest edit
166// turned into "nothing to undo" while readable older ones sat behind
167// it. Asking for a window and taking the first that survives is the
168// fix, and the window is bounded rather than open because a tab full of
169// undecryptable events would otherwise turn one undo into a scan of the
170// whole log, unsealing every row on the way.
171//
172// Twenty is a judgement, not a measurement: far enough that the case
173// this exists for — an event or two under a key we no longer hold — is
174// covered, short enough that the pathological tab costs twenty AEAD
175// opens and stops. Past that, undo reports nothing to undo, which by
176// then is the honest answer.
177const undoScan = 20
178
179// handleUndo reverts the viewer's own most recent readable edit.
180//
181// Own-edits-only, cell-level (spec §7). Multi-user undo has no correct
182// answer; this one is predictable and explainable, which is the next
183// best thing. Applied as a fresh write, so if somebody has since
184// written over that cell your undo takes it back — and is itself
185// undoable.
186func (a *App) handleUndo(w http.ResponseWriter, r *http.Request) {
187 s, ok := a.sheetFromURL(w, r)
188 if !ok {
189 return
190 }
191 viewer, ok := a.actor(r)
192 if !ok {
193 a.notFound(w, r)
194 return
195 }
196 if !a.mayWrite(s.ID, viewer) {
197 a.notFound(w, r)
198 return
199 }
200 records, err := store.History(a.db, a.sealer, store.HistoryQuery{
201 TabID: s.TabID, ActorID: viewer.ID, Limit: undoScan,
202 })
203 if err != nil {
204 a.fail(w, r, err)
205 return
206 }
207 if len(records) == 0 {
208 a.historyRedirect(w, r, s, "nothing")
209 return
210 }
211 rec := records[0]
212 if err := a.applyRaw(s, rec.RowID, rec.ColID, rec.OldRaw, viewer); err != nil {
213 a.fail(w, r, err)
214 return
215 }
216 a.historyRedirect(w, r, s, "undone")
217}
218
219// applyRaw writes a cell and fans the result out, exactly as an
220// ordinary edit does.
221//
222// Shared by restore and undo so neither can drift from the write path
223// the grid uses: same store call, same hub publish, same version.
224func (a *App) applyRaw(s SheetSummary, rowID, colID uint64, raw string, by Member) error {
225 h, err := a.openTabFor(s)
226 if err != nil {
227 return err
228 }
229 defer h.Release()
230
231 rep, err := h.SetReport(rowID, colID, raw, by.ID)
232 if err != nil {
233 return err
234 }
235 a.publishReport(s, h, rep)
236 return nil
237}
238
239func (a *App) historyRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary, notice string) {
240 if nativeNotice(w, r, notice) {
241 return
242 }
243 http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/history?notice="+notice,
244 http.StatusSeeOther)
245}
246