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
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.
13const maxCommentRunes = 2000
14
15// CommentEntry is one comment, unsealed and ready to render.
16type 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.
30type 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.
49func (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.
55func (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.
83func (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.
99func (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.
166func (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.
183func 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