| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 12 | type 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 | |
| 23 | type 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. |
| 37 | func (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 | |
| 90 | func 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. |
| 109 | func (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. |
| 131 | func (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. |
| 177 | const 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. |
| 186 | func (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. |
| 224 | func (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 | |
| 239 | func (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 | |