| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strconv" |
| 6 | |
| 7 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 8 | ) |
| 9 | |
| 10 | type commentsView struct { |
| 11 | Sheet SheetSummary |
| 12 | Threads []CommentThread |
| 13 | MayComment bool |
| 14 | Notice string |
| 15 | NoticeTone string `json:"-"` |
| 16 | } |
| 17 | |
| 18 | func commentNotice(code string) string { |
| 19 | switch code { |
| 20 | case "added": |
| 21 | return "Comment added." |
| 22 | case "resolved": |
| 23 | return "Thread resolved. It stays here, so the conversation is not lost." |
| 24 | case "empty": |
| 25 | return "A comment needs something in it." |
| 26 | case "no-cell": |
| 27 | return "That cell is not part of this sheet." |
| 28 | default: |
| 29 | return "" |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // handleComments lists every thread on a sheet. |
| 34 | // |
| 35 | // Reading needs only a viewer's grant: a comment is about the sheet, |
| 36 | // and somebody who can read the cells can read what was said about |
| 37 | // them. Writing needs commenter, which is what that role is for. |
| 38 | func (a *App) handleComments(w http.ResponseWriter, r *http.Request) { |
| 39 | s, ok := a.sheetFromURL(w, r) |
| 40 | if !ok { |
| 41 | return |
| 42 | } |
| 43 | viewer, ok := a.actor(r) |
| 44 | if !ok { |
| 45 | a.notFound(w, r) |
| 46 | return |
| 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 | threads, err := a.commentThreads(s.TabID, newPosIndex(h)) |
| 56 | if err != nil { |
| 57 | a.fail(w, r, err) |
| 58 | return |
| 59 | } |
| 60 | a.render(w, r, "comments", onSheet(s, "Comments"), http.StatusOK, commentsView{ |
| 61 | Sheet: s, Threads: threads, |
| 62 | MayComment: a.mayComment(s.ID, viewer), |
| 63 | Notice: commentNotice(r.URL.Query().Get("notice")), |
| 64 | NoticeTone: noticeTone(r.URL.Query().Get("notice")), |
| 65 | }) |
| 66 | } |
| 67 | |
| 68 | // handleCommentAdd writes a comment about a cell. |
| 69 | func (a *App) handleCommentAdd(w http.ResponseWriter, r *http.Request) { |
| 70 | s, viewer, h, ok := a.commentTarget(w, r) |
| 71 | if !ok { |
| 72 | return |
| 73 | } |
| 74 | defer h.Release() |
| 75 | |
| 76 | rowID, colID, ok := a.cellIDsFromForm(r, h) |
| 77 | if !ok { |
| 78 | a.commentRedirect(w, r, s, "no-cell") |
| 79 | return |
| 80 | } |
| 81 | if err := a.addComment(s.TabID, rowID, colID, r.FormValue("body"), viewer); err != nil { |
| 82 | // The body is not echoed. It is user content, and an error |
| 83 | // string is a log line waiting to happen (AGENTS.md). |
| 84 | a.commentRedirect(w, r, s, "empty") |
| 85 | return |
| 86 | } |
| 87 | a.commentRedirect(w, r, s, "added") |
| 88 | } |
| 89 | |
| 90 | // handleCommentResolve settles a thread. |
| 91 | func (a *App) handleCommentResolve(w http.ResponseWriter, r *http.Request) { |
| 92 | s, viewer, h, ok := a.commentTarget(w, r) |
| 93 | if !ok { |
| 94 | return |
| 95 | } |
| 96 | defer h.Release() |
| 97 | |
| 98 | rowID, colID, ok := a.cellIDsFromForm(r, h) |
| 99 | if !ok { |
| 100 | a.commentRedirect(w, r, s, "no-cell") |
| 101 | return |
| 102 | } |
| 103 | if err := a.resolveThread(s.TabID, rowID, colID, viewer); err != nil { |
| 104 | a.fail(w, r, err) |
| 105 | return |
| 106 | } |
| 107 | a.commentRedirect(w, r, s, "resolved") |
| 108 | } |
| 109 | |
| 110 | // commentTarget resolves the sheet, proves the viewer may comment, and |
| 111 | // opens the tab. The caller must Release the handle. |
| 112 | func (a *App) commentTarget(w http.ResponseWriter, r *http.Request) (SheetSummary, Member, *store.Handle, bool) { |
| 113 | s, ok := a.sheetFromURL(w, r) |
| 114 | if !ok { |
| 115 | return SheetSummary{}, Member{}, nil, false |
| 116 | } |
| 117 | viewer, ok := a.actor(r) |
| 118 | if !ok { |
| 119 | a.notFound(w, r) |
| 120 | return SheetSummary{}, Member{}, nil, false |
| 121 | } |
| 122 | if !a.mayComment(s.ID, viewer) { |
| 123 | // 404 like every other refusal: a viewer who cannot comment |
| 124 | // learns nothing about who can. |
| 125 | a.notFound(w, r) |
| 126 | return SheetSummary{}, Member{}, nil, false |
| 127 | } |
| 128 | h, err := a.openTabFor(s) |
| 129 | if err != nil { |
| 130 | a.fail(w, r, err) |
| 131 | return SheetSummary{}, Member{}, nil, false |
| 132 | } |
| 133 | return s, viewer, h, true |
| 134 | } |
| 135 | |
| 136 | // cellIDsFromForm turns the r/c a form submitted into stable ids. |
| 137 | // |
| 138 | // EnsureRow/EnsureCol, so a comment can be left on a cell nobody has |
| 139 | // typed into yet — which is a real thing to want ("this column should |
| 140 | // hold X"). It is also why comments anchor on ids: the row exists from |
| 141 | // the moment somebody talks about it. |
| 142 | func (a *App) cellIDsFromForm(r *http.Request, h *store.Handle) (uint64, uint64, bool) { |
| 143 | row, err1 := strconv.Atoi(r.FormValue("r")) |
| 144 | col, err2 := strconv.Atoi(r.FormValue("c")) |
| 145 | if err1 != nil || err2 != nil || row < 0 || col < 0 || |
| 146 | row >= maxWindowRows || col >= tableCols { |
| 147 | return 0, 0, false |
| 148 | } |
| 149 | rowID, err := h.EnsureRow(row) |
| 150 | if err != nil { |
| 151 | return 0, 0, false |
| 152 | } |
| 153 | colID, err := h.EnsureCol(col) |
| 154 | if err != nil { |
| 155 | return 0, 0, false |
| 156 | } |
| 157 | return rowID, colID, true |
| 158 | } |
| 159 | |
| 160 | func (a *App) commentRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary, notice string) { |
| 161 | if nativeNotice(w, r, notice) { |
| 162 | return |
| 163 | } |
| 164 | http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/comments?notice="+notice, |
| 165 | http.StatusSeeOther) |
| 166 | } |
| 167 | |