| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // maxCommentRunes caps one comment. |
| 10 | // |
| 11 | // A note about a cell, not a document. Long enough for a real |
| 12 | // paragraph, short enough that a thread stays readable beside the grid. |
| 13 | const maxCommentRunes = 2000 |
| 14 | |
| 15 | // CommentEntry is one comment, unsealed and ready to render. |
| 16 | type CommentEntry struct { |
| 17 | ID uint64 |
| 18 | Author string |
| 19 | Body string |
| 20 | When time.Time |
| 21 | Resolved bool |
| 22 | } |
| 23 | |
| 24 | // CommentThread is every comment on one cell. |
| 25 | // |
| 26 | // A thread is defined by the cell, not by a parent id. That is the |
| 27 | // simplest structure that matches what people do — they discuss a cell |
| 28 | // — and it is the one that survives adopting Docs' primitive later, |
| 29 | // since their thread will still be anchored somewhere. |
| 30 | type CommentThread struct { |
| 31 | RowID, ColID uint64 |
| 32 | // RowPos and ColPos are what a form posts back. The stable ids are |
| 33 | // how the thread is anchored; the positions are how a person and |
| 34 | // the routes address a cell, and the resolve form has to speak the |
| 35 | // same language cellIDsFromForm reads. Sending the ids instead is |
| 36 | // a mismatch that resolves the wrong thread — or, more likely, |
| 37 | // none, since an id and a position collide only by accident. |
| 38 | RowPos, ColPos int |
| 39 | Ref string // A1 notation, for reading |
| 40 | Entries []CommentEntry |
| 41 | Resolved bool |
| 42 | } |
| 43 | |
| 44 | // mayComment is the gate the comment routes use. |
| 45 | // |
| 46 | // Commenter is enough — that is the entire point of the role, which |
| 47 | // until now granted nothing beyond reading. Editors and owners clear it |
| 48 | // too, because Allows is a floor. |
| 49 | func (a *App) mayComment(sheetID uint64, m Member) bool { |
| 50 | role, ok := a.roleFor(sheetID, m) |
| 51 | return ok && role.Allows(SheetCommenter) |
| 52 | } |
| 53 | |
| 54 | // addComment writes a comment on a cell. |
| 55 | func (a *App) addComment(tabID, rowID, colID uint64, body string, by Member) error { |
| 56 | if a.db == nil { |
| 57 | return errNoDB |
| 58 | } |
| 59 | if a.sealer == nil { |
| 60 | return errNoSealer |
| 61 | } |
| 62 | clean := strings.TrimSpace(body) |
| 63 | if clean == "" { |
| 64 | return errors.New("sheets: a comment needs something in it") |
| 65 | } |
| 66 | if r := []rune(clean); len(r) > maxCommentRunes { |
| 67 | clean = string(r[:maxCommentRunes]) |
| 68 | } |
| 69 | sealed, err := a.sealer.Seal("comment.body", []byte(clean)) |
| 70 | if err != nil { |
| 71 | return err |
| 72 | } |
| 73 | return a.db.Create(&Comment{ |
| 74 | TabID: tabID, RowID: rowID, ColID: colID, |
| 75 | AuthorID: by.ID, BodySealed: sealed, |
| 76 | }).Error |
| 77 | } |
| 78 | |
| 79 | // resolveThread marks every comment on a cell as settled. |
| 80 | // |
| 81 | // The whole cell, not one comment: a thread is the unit people think |
| 82 | // in, and resolving one message of a conversation says nothing useful. |
| 83 | func (a *App) resolveThread(tabID, rowID, colID uint64, by Member) error { |
| 84 | if a.db == nil { |
| 85 | return errNoDB |
| 86 | } |
| 87 | now := time.Now().UTC() |
| 88 | return a.db.Model(&Comment{}). |
| 89 | Where("tab_id = ? AND row_id = ? AND col_id = ? AND resolved_at IS NULL", |
| 90 | tabID, rowID, colID). |
| 91 | Updates(map[string]any{"resolved_at": now, "resolved_by": by.ID}).Error |
| 92 | } |
| 93 | |
| 94 | // commentThreads reads every thread on a tab, unsealed. |
| 95 | // |
| 96 | // Threads are keyed by cell and returned in a stable order — resolved |
| 97 | // last, then by first comment — so a page of them does not reshuffle |
| 98 | // between loads. |
| 99 | func (a *App) commentThreads(tabID uint64, idx posIndex) ([]CommentThread, error) { |
| 100 | if a.db == nil { |
| 101 | return nil, errNoDB |
| 102 | } |
| 103 | if a.sealer == nil { |
| 104 | return nil, errNoSealer |
| 105 | } |
| 106 | var rows []Comment |
| 107 | if err := a.db.Where("tab_id = ?", tabID).Order("id ASC").Find(&rows).Error; err != nil { |
| 108 | return nil, err |
| 109 | } |
| 110 | names := a.memberNames() |
| 111 | |
| 112 | type key struct{ r, c uint64 } |
| 113 | order := make([]key, 0, len(rows)) |
| 114 | byCell := map[key]*CommentThread{} |
| 115 | |
| 116 | for _, c := range rows { |
| 117 | body, err := a.sealer.Open("comment.body", c.BodySealed) |
| 118 | if err != nil { |
| 119 | // One unreadable comment must not take the thread with it. |
| 120 | continue |
| 121 | } |
| 122 | k := key{c.RowID, c.ColID} |
| 123 | th, ok := byCell[k] |
| 124 | if !ok { |
| 125 | rp, okR := idx.rowPos[c.RowID] |
| 126 | cp, okC := idx.colPos[c.ColID] |
| 127 | if !okR || !okC { |
| 128 | // A comment whose row or column is gone has nothing to |
| 129 | // point at. Skipped rather than shown against a |
| 130 | // coordinate we would have to invent. |
| 131 | continue |
| 132 | } |
| 133 | th = &CommentThread{ |
| 134 | RowID: c.RowID, ColID: c.ColID, |
| 135 | RowPos: rp, ColPos: cp, |
| 136 | Ref: colLabel(cp) + itoaPos(rp+1), Resolved: true, |
| 137 | } |
| 138 | byCell[k] = th |
| 139 | order = append(order, k) |
| 140 | } |
| 141 | th.Entries = append(th.Entries, CommentEntry{ |
| 142 | ID: c.ID, Author: names[c.AuthorID], Body: string(body), |
| 143 | When: c.CreatedAt, Resolved: c.ResolvedAt != nil, |
| 144 | }) |
| 145 | if c.ResolvedAt == nil { |
| 146 | th.Resolved = false |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | out := make([]CommentThread, 0, len(order)) |
| 151 | for _, k := range order { |
| 152 | if !byCell[k].Resolved { |
| 153 | out = append(out, *byCell[k]) |
| 154 | } |
| 155 | } |
| 156 | for _, k := range order { |
| 157 | if byCell[k].Resolved { |
| 158 | out = append(out, *byCell[k]) |
| 159 | } |
| 160 | } |
| 161 | return out, nil |
| 162 | } |
| 163 | |
| 164 | // commentedCells reports which cells carry an unresolved thread, so the |
| 165 | // grid and table can mark them. |
| 166 | func (a *App) commentedCells(tabID uint64) map[[2]uint64]bool { |
| 167 | out := map[[2]uint64]bool{} |
| 168 | if a.db == nil { |
| 169 | return out |
| 170 | } |
| 171 | var rows []Comment |
| 172 | if err := a.db.Where("tab_id = ? AND resolved_at IS NULL", tabID).Find(&rows).Error; err != nil { |
| 173 | return out |
| 174 | } |
| 175 | for _, c := range rows { |
| 176 | out[[2]uint64{c.RowID, c.ColID}] = true |
| 177 | } |
| 178 | return out |
| 179 | } |
| 180 | |
| 181 | // itoaPos is strconv.Itoa, named here so the coordinate-building code |
| 182 | // reads the same as colLabel beside it. |
| 183 | func itoaPos(n int) string { |
| 184 | if n == 0 { |
| 185 | return "0" |
| 186 | } |
| 187 | var b []byte |
| 188 | for n > 0 { |
| 189 | b = append([]byte{byte('0' + n%10)}, b...) |
| 190 | n /= 10 |
| 191 | } |
| 192 | return string(b) |
| 193 | } |
| 194 | |