| 1 | // handle_grid.go is the grid-growth and format-write surface a bulk |
| 2 | // importer needs and ordinary editing does not: EnsureRow/EnsureCol grow |
| 3 | // a tab's row/col set on demand for a cell addressed by spreadsheet |
| 4 | // position, SetFormat interns and assigns a cell's format (the write side |
| 5 | // of Handle.Format, which is read-only — see handle.go), and RawInput |
| 6 | // reads back the exact text a cell was last Set with, so an exporter can |
| 7 | // tell a formula from a literal and re-emit the formula verbatim rather |
| 8 | // than only its computed result. |
| 9 | package store |
| 10 | |
| 11 | import ( |
| 12 | "crypto/sha256" |
| 13 | "encoding/hex" |
| 14 | "errors" |
| 15 | "fmt" |
| 16 | "strconv" |
| 17 | "strings" |
| 18 | |
| 19 | "gorm.io/gorm" |
| 20 | ) |
| 21 | |
| 22 | // EnsureRow returns the id of the row at pos, creating it if this tab has |
| 23 | // none there yet. Idempotent: a second call for the same pos returns the |
| 24 | // same id without writing anything. |
| 25 | // |
| 26 | // (TabID, Position) is a uniqueIndex (models.go), not just a plain one: |
| 27 | // within THIS handle, its own mutex plus rowByPos already make a |
| 28 | // duplicate Create impossible. But a second process — another app |
| 29 | // instance, a retried background job, a double-clicked import — builds |
| 30 | // its own rowByPos and can lose exactly this race: both see no row at |
| 31 | // pos, both Create. Without the constraint that succeeds twice, leaving |
| 32 | // two Row records claiming one grid coordinate. With it, the loser's |
| 33 | // Create fails on the unique violation, which is not treated as a |
| 34 | // failure here: it means someone else already created the row this call |
| 35 | // wanted, so this re-reads it by (TabID, pos) instead of propagating the |
| 36 | // error — the same recovery shape internFormat uses below for |
| 37 | // Format.Digest's own uniqueIndex. |
| 38 | func (h *Handle) EnsureRow(pos int) (uint64, error) { |
| 39 | h.mu.Lock() |
| 40 | defer h.mu.Unlock() |
| 41 | if id, ok := h.rowByPos[pos]; ok { |
| 42 | return id, nil |
| 43 | } |
| 44 | r := Row{TabID: h.tabID, Position: pos} |
| 45 | if err := h.db.Create(&r).Error; err != nil { |
| 46 | var existing Row |
| 47 | if err2 := h.db.Where("tab_id = ? AND position = ?", h.tabID, pos).First(&existing).Error; err2 == nil { |
| 48 | h.rowPos[existing.ID] = pos |
| 49 | h.rowByPos[pos] = existing.ID |
| 50 | return existing.ID, nil |
| 51 | } |
| 52 | return 0, fmt.Errorf("store: create row at position %d: %w", pos, err) |
| 53 | } |
| 54 | h.rowPos[r.ID] = pos |
| 55 | h.rowByPos[pos] = r.ID |
| 56 | return r.ID, nil |
| 57 | } |
| 58 | |
| 59 | // EnsureCol is EnsureRow's column counterpart, including the same |
| 60 | // unique-violation-means-someone-else-won-the-race recovery. |
| 61 | func (h *Handle) EnsureCol(pos int) (uint64, error) { |
| 62 | h.mu.Lock() |
| 63 | defer h.mu.Unlock() |
| 64 | if id, ok := h.colByPos[pos]; ok { |
| 65 | return id, nil |
| 66 | } |
| 67 | c := Col{TabID: h.tabID, Position: pos} |
| 68 | if err := h.db.Create(&c).Error; err != nil { |
| 69 | var existing Col |
| 70 | if err2 := h.db.Where("tab_id = ? AND position = ?", h.tabID, pos).First(&existing).Error; err2 == nil { |
| 71 | h.colPos[existing.ID] = pos |
| 72 | h.colByPos[pos] = existing.ID |
| 73 | return existing.ID, nil |
| 74 | } |
| 75 | return 0, fmt.Errorf("store: create col at position %d: %w", pos, err) |
| 76 | } |
| 77 | h.colPos[c.ID] = pos |
| 78 | h.colByPos[pos] = c.ID |
| 79 | return c.ID, nil |
| 80 | } |
| 81 | |
| 82 | // PosID pairs a row or column's spreadsheet position with its stable id. |
| 83 | type PosID struct { |
| 84 | Pos int |
| 85 | ID uint64 |
| 86 | } |
| 87 | |
| 88 | // GridRows returns every row this tab has a Row record for, sorted by |
| 89 | // position ascending. An exporter walks this (and GridCols) to know which |
| 90 | // spreadsheet coordinates to visit — Handle otherwise only answers |
| 91 | // questions about one cell at a time. |
| 92 | func (h *Handle) GridRows() []PosID { |
| 93 | h.mu.Lock() |
| 94 | defer h.mu.Unlock() |
| 95 | return sortedPosIDs(h.rowByPos) |
| 96 | } |
| 97 | |
| 98 | // GridCols is GridRows' column counterpart. |
| 99 | func (h *Handle) GridCols() []PosID { |
| 100 | h.mu.Lock() |
| 101 | defer h.mu.Unlock() |
| 102 | return sortedPosIDs(h.colByPos) |
| 103 | } |
| 104 | |
| 105 | func sortedPosIDs(byPos map[int]uint64) []PosID { |
| 106 | out := make([]PosID, 0, len(byPos)) |
| 107 | for pos, id := range byPos { |
| 108 | out = append(out, PosID{Pos: pos, ID: id}) |
| 109 | } |
| 110 | for i := 1; i < len(out); i++ { |
| 111 | for j := i; j > 0 && out[j].Pos < out[j-1].Pos; j-- { |
| 112 | out[j], out[j-1] = out[j-1], out[j] |
| 113 | } |
| 114 | } |
| 115 | return out |
| 116 | } |
| 117 | |
| 118 | // CellCoord is a cell's stable, real identity — the (row, col) shape |
| 119 | // Handle's own public methods (Set, Value, Format, RawInput, SetFormat) |
| 120 | // all take. |
| 121 | type CellCoord struct{ RowID, ColID uint64 } |
| 122 | |
| 123 | // Cells returns every cell this tab actually has a Cell row for — content, |
| 124 | // a format, or both (models.go: Cell is sparse, so this is the complete |
| 125 | // non-default set). An exporter walking GridRows x GridCols directly |
| 126 | // would visit every row/col PAIR, which is quadratic in the wrong case |
| 127 | // (many cells scattered across many distinct rows and columns, e.g. a |
| 128 | // diagonal); this is the actual population, read straight from storage |
| 129 | // the same way Format already does (unlike Value, which reads through |
| 130 | // the in-memory graph — see Format's own doc comment on handle.go). |
| 131 | func (h *Handle) Cells() ([]CellCoord, error) { |
| 132 | var cells []Cell |
| 133 | if err := h.db.Select("row_id", "col_id"). |
| 134 | Where("tab_id = ?", h.tabID).Find(&cells).Error; err != nil { |
| 135 | return nil, fmt.Errorf("store: list cells: %w", err) |
| 136 | } |
| 137 | out := make([]CellCoord, len(cells)) |
| 138 | for i, c := range cells { |
| 139 | out[i] = CellCoord{RowID: c.RowID, ColID: c.ColID} |
| 140 | } |
| 141 | return out, nil |
| 142 | } |
| 143 | |
| 144 | // RawInput returns the exact text a cell was last Set with (or loaded |
| 145 | // with, on Open) — never the computed value. false means this handle has |
| 146 | // nothing recorded for the coordinate: either it is not part of the tab, |
| 147 | // or it has always been blank. |
| 148 | func (h *Handle) RawInput(rowID, colID uint64) (string, bool) { |
| 149 | h.mu.Lock() |
| 150 | defer h.mu.Unlock() |
| 151 | key, ok := h.cellKey(rowID, colID) |
| 152 | if !ok { |
| 153 | return "", false |
| 154 | } |
| 155 | raw, ok := h.rawText[key] |
| 156 | return raw, ok |
| 157 | } |
| 158 | |
| 159 | // SetFormat interns f (Format is deduplicated by content — models.go) and |
| 160 | // assigns it to the cell at (rowID, colID), creating a Cell row with |
| 161 | // empty sealed input if the cell is otherwise blank: FormatID lives on |
| 162 | // Cell, so a formatted-but-empty cell (a coloured header nothing has been |
| 163 | // typed into yet) still needs a row to carry it. |
| 164 | func (h *Handle) SetFormat(rowID, colID uint64, f Format) error { |
| 165 | h.mu.Lock() |
| 166 | defer h.mu.Unlock() |
| 167 | if _, ok := h.cellKey(rowID, colID); !ok { |
| 168 | return fmt.Errorf("store: row %d col %d is not part of tab %d", rowID, colID, h.tabID) |
| 169 | } |
| 170 | formatID, err := h.internFormat(f) |
| 171 | if err != nil { |
| 172 | return err |
| 173 | } |
| 174 | return h.assignFormat(rowID, colID, formatID) |
| 175 | } |
| 176 | |
| 177 | // internFormat finds the existing Format row matching f's content |
| 178 | // (by digest) or creates one. A duplicate-key race against a concurrent |
| 179 | // interner of the identical format is resolved by re-reading rather than |
| 180 | // failing: the digest's uniqueIndex is what makes that safe. |
| 181 | func (h *Handle) internFormat(f Format) (uint64, error) { |
| 182 | digest := formatDigest(f) |
| 183 | |
| 184 | var existing Format |
| 185 | err := h.db.Where("digest = ?", digest).First(&existing).Error |
| 186 | if err == nil { |
| 187 | return existing.ID, nil |
| 188 | } |
| 189 | if !errors.Is(err, gorm.ErrRecordNotFound) { |
| 190 | return 0, fmt.Errorf("store: look up format: %w", err) |
| 191 | } |
| 192 | |
| 193 | f.Digest = digest |
| 194 | if err := h.db.Create(&f).Error; err != nil { |
| 195 | // Another writer interned the same content between the lookup |
| 196 | // above and this Create — re-read rather than propagating what |
| 197 | // is, from the caller's point of view, not really a failure. |
| 198 | var again Format |
| 199 | if err2 := h.db.Where("digest = ?", digest).First(&again).Error; err2 == nil { |
| 200 | return again.ID, nil |
| 201 | } |
| 202 | return 0, fmt.Errorf("store: create format: %w", err) |
| 203 | } |
| 204 | return f.ID, nil |
| 205 | } |
| 206 | |
| 207 | // formatDigest canonicalizes f's fields into the string Format.Digest |
| 208 | // hashes — every field that participates in Format's own uniqueIndex |
| 209 | // (models.go), and none that doesn't (ID, Digest itself). |
| 210 | func formatDigest(f Format) string { |
| 211 | var sb strings.Builder |
| 212 | sb.WriteString(f.NumberFmt) |
| 213 | sb.WriteByte('|') |
| 214 | sb.WriteString(strconv.FormatBool(f.Bold)) |
| 215 | sb.WriteByte('|') |
| 216 | sb.WriteString(strconv.FormatBool(f.Italic)) |
| 217 | sb.WriteByte('|') |
| 218 | if f.FillIntentH != nil { |
| 219 | sb.WriteString(strconv.FormatFloat(*f.FillIntentH, 'g', -1, 64)) |
| 220 | } |
| 221 | sb.WriteByte('|') |
| 222 | if f.FillIntentC != nil { |
| 223 | sb.WriteString(strconv.FormatFloat(*f.FillIntentC, 'g', -1, 64)) |
| 224 | } |
| 225 | sb.WriteByte('|') |
| 226 | sb.WriteString(f.FillHex) |
| 227 | sb.WriteByte('|') |
| 228 | // InkHex participates for the same reason every other field does: |
| 229 | // formats are interned by this digest, so a field left out here |
| 230 | // makes two genuinely different formats collide onto one row, and |
| 231 | // the second cell silently adopts the first's colour. |
| 232 | sb.WriteString(f.InkHex) |
| 233 | sb.WriteByte('|') |
| 234 | if f.InkIntentH != nil { |
| 235 | sb.WriteString(strconv.FormatFloat(*f.InkIntentH, 'g', -1, 64)) |
| 236 | } |
| 237 | sb.WriteByte('|') |
| 238 | if f.InkIntentC != nil { |
| 239 | sb.WriteString(strconv.FormatFloat(*f.InkIntentC, 'g', -1, 64)) |
| 240 | } |
| 241 | sb.WriteByte('|') |
| 242 | sb.WriteString(f.Align) |
| 243 | sum := sha256.Sum256([]byte(sb.String())) |
| 244 | return hex.EncodeToString(sum[:]) |
| 245 | } |
| 246 | |
| 247 | // assignFormat points the cell at (rowID, colID)'s FormatID at formatID, |
| 248 | // creating the Cell row (with empty sealed input) if none exists yet. |
| 249 | func (h *Handle) assignFormat(rowID, colID, formatID uint64) error { |
| 250 | var cell Cell |
| 251 | err := h.db.Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID). |
| 252 | First(&cell).Error |
| 253 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 254 | sealedEmpty, serr := h.sealer.Seal("cell.input", []byte("")) |
| 255 | if serr != nil { |
| 256 | return fmt.Errorf("store: seal empty input for format-only cell: %w", serr) |
| 257 | } |
| 258 | id := formatID |
| 259 | return h.db.Create(&Cell{ |
| 260 | TabID: h.tabID, RowID: rowID, ColID: colID, |
| 261 | InputSealed: sealedEmpty, FormatID: &id, |
| 262 | }).Error |
| 263 | } |
| 264 | if err != nil { |
| 265 | return fmt.Errorf("store: look up cell for format assignment: %w", err) |
| 266 | } |
| 267 | id := formatID |
| 268 | return h.db.Model(&Cell{}). |
| 269 | Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID). |
| 270 | Update("format_id", &id).Error |
| 271 | } |
| 272 | |