| 1 |
|
package web |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"bytes" |
| 5 |
|
"crypto/sha256" |
| 6 |
|
"encoding/hex" |
| 7 |
|
"fmt" |
| 8 |
|
"html/template" |
| 9 |
|
"log/slog" |
| 10 |
|
"strings" |
| 11 |
|
|
| 12 |
|
"go.bigb.es/auxilia/scribe" |
| 13 |
|
|
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/prosediff" |
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 17 |
|
) |
| 18 |
|
|
| 19 |
|
// inlineSimilarityThreshold is the Phase 0 verdict's presentation switch: a |
| 20 |
|
// modified prose block whose token similarity is at or above it renders as an |
| 21 |
|
// inline word diff, and one below it renders as a paired old/new region. |
| 22 |
|
// |
| 23 |
|
// 13% of real prose modifications shred into interleaved fragments — those |
| 24 |
|
// paragraphs really were rewritten sentence by sentence — and every one of them |
| 25 |
|
// scores at or below 0.73. Rendering them inline makes one review in eight |
| 26 |
|
// unreadable, which is the one where the agent changed the most. prosediff |
| 27 |
|
// exports BlockChange.Similarity for exactly this decision and computes no HTML |
| 28 |
|
// itself; this is where the decision is made. |
| 29 |
|
const inlineSimilarityThreshold = 0.75 |
| 30 |
|
|
| 31 |
|
// diffView is the whole rendered diff of one document, ready for the proposal |
| 32 |
|
// template. Unchanged reports the degenerate case — a proposal that touches a |
| 33 |
|
// document without changing it — so the page can say so rather than show an |
| 34 |
|
// empty diff. |
| 35 |
|
type diffView struct { |
| 36 |
|
HTML template.HTML |
| 37 |
|
Stats prosediff.Stats |
| 38 |
|
Unchanged bool |
| 39 |
|
|
| 40 |
|
// Unplaced are this document's threads that no rendered block claimed. The |
| 41 |
|
// page shows them in its own area: a comment whose anchor is lost, or whose |
| 42 |
|
// block this diff does not render, must still be visible somewhere. |
| 43 |
|
Unplaced []service.Thread |
| 44 |
|
} |
| 45 |
|
|
| 46 |
|
// docDiff is one document's review: the two revisions to compare, the identity |
| 47 |
|
// its comments anchor to, the threads already resolved against this revision, |
| 48 |
|
// and who may act on them. |
| 49 |
|
type docDiff struct { |
| 50 |
|
// DocID is the document's anchoring key — see docIDFor. |
| 51 |
|
DocID string |
| 52 |
|
// Path is the document's path on the proposal branch, which the compose form |
| 53 |
|
// posts back so the anchor is rebuilt against the branch, not the form. |
| 54 |
|
Path string |
| 55 |
|
// Base is the approved content the proposal was made against; Proposed is |
| 56 |
|
// what the branch says now. |
| 57 |
|
Base, Proposed []byte |
| 58 |
|
// Threads are this document's threads, already run through |
| 59 |
|
// service.AnchorThreads: State and Block are meaningless before that. |
| 60 |
|
Threads []service.Thread |
| 61 |
|
Controls reviewControls |
| 62 |
|
} |
| 63 |
|
|
| 64 |
|
// blockKey identifies a block of one revision of one document: which side it is |
| 65 |
|
// on and its position in that side's segmentation. It is the only key a thread |
| 66 |
|
// is placed by — see docRenderer. |
| 67 |
|
type blockKey struct { |
| 68 |
|
side core.CommentSide |
| 69 |
|
ordinal int |
| 70 |
|
} |
| 71 |
|
|
| 72 |
|
// renderDocDiff diffs the approved (old) and proposed (new) source of one |
| 73 |
|
// document and renders it as a line-numbered unified diff, with every block's |
| 74 |
|
// review threads and, for the owner, the form that opens a new one. |
| 75 |
|
// |
| 76 |
|
// The page is a table because the gutter has to be a gutter: two number tracks |
| 77 |
|
// that stay aligned with the first visual line of a prose line that wraps three |
| 78 |
|
// times. Selection is by line and anchoring is by block, so every row carries |
| 79 |
|
// the anchor of the block it belongs to and the block's first row carries the id |
| 80 |
|
// a comment link scrolls to. |
| 81 |
|
// |
| 82 |
|
// The arithmetic — which number belongs in which track, and which lines a fold |
| 83 |
|
// may hide — is diffrows.go's, deliberately kept out of here. What is left is |
| 84 |
|
// escaping and concatenation: every piece of document content passes through |
| 85 |
|
// template.HTMLEscapeString, and the only markup this produces is its own |
| 86 |
|
// structure plus the thread markup html/template escapes for it. |
| 87 |
28 |
func renderDocDiff(in docDiff) diffView { |
| 88 |
28 |
d := prosediff.Compare(in.Base, in.Proposed) |
| 89 |
28 |
view := diffView{Stats: d.Stats, Unchanged: !d.Stats.Changed()} |
| 90 |
28 |
|
| 91 |
28 |
r := newDocRenderer(in, d) |
| 92 |
28 |
if view.Unchanged { |
| 93 |
2 |
// Nothing is rendered, so nothing can hold a thread. Handing them all |
| 94 |
2 |
// back keeps the invariant this renderer is built on: every thread comes |
| 95 |
2 |
// out either attached to a block or in Unplaced, and never neither. |
| 96 |
2 |
view.Unplaced = r.unplaced() |
| 97 |
2 |
return view |
| 98 |
2 |
} |
| 99 |
|
|
| 100 |
26 |
var b strings.Builder |
| 101 |
26 |
b.WriteString(`<div class="prosediff"><table class="ph-diff">`) |
| 102 |
34 |
for _, g := range groupRows(buildRows(d.Changes, r.blockInfo)) { |
| 103 |
34 |
r.writeGroup(&b, g) |
| 104 |
34 |
} |
| 105 |
26 |
b.WriteString(`</table></div>`) |
| 106 |
26 |
|
| 107 |
26 |
view.HTML = template.HTML(b.String()) |
| 108 |
26 |
view.Unplaced = r.unplaced() |
| 109 |
26 |
return view |
| 110 |
|
} |
| 111 |
|
|
| 112 |
|
// docRenderer renders the rows of one document's diff. It holds the anchor |
| 113 |
|
// numbering of both revisions and the threads still waiting for a block: a |
| 114 |
|
// block claims its threads as its notes row is written, and whatever is left |
| 115 |
|
// over at the end never had a block on the page. |
| 116 |
|
type docRenderer struct { |
| 117 |
|
in docDiff |
| 118 |
|
// anchors is each side's blocks, numbered within their heading path. |
| 119 |
|
anchors map[core.CommentSide][]core.AnchorBlock |
| 120 |
|
pending map[blockKey][]service.Thread |
| 121 |
|
// foldPrefix scopes this document's fold checkbox ids. A proposal page |
| 122 |
|
// renders several documents into one HTML document, and two folds sharing an |
| 123 |
|
// id would toggle each other. |
| 124 |
|
foldPrefix string |
| 125 |
|
} |
| 126 |
|
|
| 127 |
28 |
func newDocRenderer(in docDiff, d *prosediff.Diff) *docRenderer { |
| 128 |
28 |
sum := sha256.Sum256([]byte(in.Path)) |
| 129 |
28 |
r := &docRenderer{ |
| 130 |
28 |
in: in, |
| 131 |
28 |
anchors: map[core.CommentSide][]core.AnchorBlock{ |
| 132 |
28 |
core.SideNew: blockAnchors(d.NewBlocks), |
| 133 |
28 |
core.SideOld: blockAnchors(d.OldBlocks), |
| 134 |
28 |
}, |
| 135 |
28 |
pending: make(map[blockKey][]service.Thread, len(in.Threads)), |
| 136 |
28 |
foldPrefix: hex.EncodeToString(sum[:])[:8], |
| 137 |
28 |
} |
| 138 |
28 |
// A thread is placed by (side, block ordinal) and by nothing else. The |
| 139 |
28 |
// anchor resolution already decided which block it belongs to, and any |
| 140 |
28 |
// second-guessing here would be the one thing the anchor model forbids: a |
| 141 |
28 |
// comment quietly moved onto a neighbouring paragraph. A thread whose anchor |
| 142 |
28 |
// did not resolve (Block < 0) is never placed at all. |
| 143 |
28 |
for _, t := range in.Threads { |
| 144 |
9 |
if t.Block < 0 { |
| 145 |
2 |
continue |
| 146 |
|
} |
| 147 |
7 |
k := blockKey{sideOf(t.Anchor.Side), t.Block} |
| 148 |
7 |
r.pending[k] = append(r.pending[k], t) |
| 149 |
|
} |
| 150 |
28 |
return r |
| 151 |
|
} |
| 152 |
|
|
| 153 |
|
// unplaced reports the threads no block claimed. It walks the input rather than |
| 154 |
|
// the leftover map so the order is the one the service listed them in, not a |
| 155 |
|
// map's. |
| 156 |
28 |
func (r *docRenderer) unplaced() []service.Thread { |
| 157 |
28 |
out := make([]service.Thread, 0, len(r.pending)) |
| 158 |
28 |
for _, t := range r.in.Threads { |
| 159 |
9 |
if t.Block < 0 { |
| 160 |
2 |
out = append(out, t) |
| 161 |
2 |
continue |
| 162 |
|
} |
| 163 |
7 |
k := blockKey{sideOf(t.Anchor.Side), t.Block} |
| 164 |
7 |
if _, still := r.pending[k]; still { |
| 165 |
2 |
out = append(out, t) |
| 166 |
2 |
} |
| 167 |
|
} |
| 168 |
28 |
return out |
| 169 |
|
} |
| 170 |
|
|
| 171 |
|
// target returns the anchor of the block a change offers to comment on, and |
| 172 |
|
// whether it offers one at all. |
| 173 |
|
// |
| 174 |
|
// A comment goes on the new side, which is the text under review; the old side |
| 175 |
|
// is for a block the proposal deletes, where there is no new text to point at. |
| 176 |
|
// A move-out offers nothing: it is a pointer to text that is rendered at its |
| 177 |
|
// new position, and anchoring it here would give one paragraph two places to be |
| 178 |
|
// commented on. |
| 179 |
124 |
func (r *docRenderer) target(c prosediff.BlockChange) (core.CommentAnchor, blockKey, bool) { |
| 180 |
124 |
var side core.CommentSide |
| 181 |
124 |
var blk *prosediff.Block |
| 182 |
124 |
switch c.Kind { |
| 183 |
0 |
case prosediff.ChangeMoveOut: |
| 184 |
0 |
return core.CommentAnchor{}, blockKey{}, false |
| 185 |
3 |
case prosediff.ChangeDelete: |
| 186 |
3 |
side, blk = core.SideOld, c.Old |
| 187 |
121 |
default: |
| 188 |
121 |
side, blk = core.SideNew, c.New |
| 189 |
|
} |
| 190 |
124 |
blocks := r.anchors[side] |
| 191 |
124 |
if blk == nil || blk.Ordinal < 0 || blk.Ordinal >= len(blocks) { |
| 192 |
0 |
return core.CommentAnchor{}, blockKey{}, false |
| 193 |
0 |
} |
| 194 |
124 |
ab := blocks[blk.Ordinal] |
| 195 |
124 |
anchor := core.CommentAnchor{ |
| 196 |
124 |
DocID: r.in.DocID, |
| 197 |
124 |
HeadingPath: ab.HeadingPath, |
| 198 |
124 |
Index: ab.Index, |
| 199 |
124 |
BlockHash: ab.Hash, |
| 200 |
124 |
Side: side, |
| 201 |
124 |
} |
| 202 |
124 |
return anchor, blockKey{side, blk.Ordinal}, true |
| 203 |
|
} |
| 204 |
|
|
| 205 |
|
// blockInfo answers, for the row builder, the two questions about a change that |
| 206 |
|
// are not in the change: what it anchors to, and whether it needs a notes row. |
| 207 |
|
// |
| 208 |
|
// It counts this block's pending threads without claiming them — claiming |
| 209 |
|
// happens when the notes row is written, which is the one place that can |
| 210 |
|
// guarantee they were actually rendered. A block the anchor numbering does not |
| 211 |
|
// cover is still given rows, because losing document content would be worse |
| 212 |
|
// than losing its comment affordance; it just carries no id and offers no form. |
| 213 |
124 |
func (r *docRenderer) blockInfo(c prosediff.BlockChange) blockInfo { |
| 214 |
124 |
anchor, key, ok := r.target(c) |
| 215 |
124 |
if !ok { |
| 216 |
0 |
return blockInfo{} |
| 217 |
0 |
} |
| 218 |
124 |
threads := len(r.pending[key]) > 0 |
| 219 |
124 |
return blockInfo{ |
| 220 |
124 |
Anchor: anchor, |
| 221 |
124 |
Key: key, |
| 222 |
124 |
Commentable: true, |
| 223 |
124 |
HasThreads: threads, |
| 224 |
124 |
Notes: threads || r.in.Controls.Owner, |
| 225 |
124 |
} |
| 226 |
|
} |
| 227 |
|
|
| 228 |
|
// writeGroup renders one <tbody>. A fold group opens with the checkbox and |
| 229 |
|
// label that reveal it: a real form control rather than a script-driven button, |
| 230 |
|
// so an unchanged run can be opened with JavaScript off. |
| 231 |
34 |
func (r *docRenderer) writeGroup(b *strings.Builder, g rowGroup) { |
| 232 |
34 |
if !g.Fold { |
| 233 |
30 |
b.WriteString(`<tbody>`) |
| 234 |
30 |
} else { |
| 235 |
4 |
id := fmt.Sprintf("fold-%s-%d", r.foldPrefix, g.Index) |
| 236 |
4 |
b.WriteString(`<tbody class="ph-fold"><tr class="ph-fold-head"><td colspan="4">`) |
| 237 |
4 |
fmt.Fprintf(b, `<input type="checkbox" class="ph-fold-cb" id="%s"><label for="%s">%d unchanged lines</label>`, |
| 238 |
4 |
id, id, g.Hidden) |
| 239 |
4 |
b.WriteString(`</td></tr>`) |
| 240 |
4 |
} |
| 241 |
180 |
for _, row := range g.Rows { |
| 242 |
180 |
r.writeRow(b, row) |
| 243 |
180 |
} |
| 244 |
34 |
b.WriteString(`</tbody>`) |
| 245 |
|
} |
| 246 |
|
|
| 247 |
|
// writeRow renders one row of the table: two number cells, a sign, and the |
| 248 |
|
// line. |
| 249 |
180 |
func (r *docRenderer) writeRow(b *strings.Builder, row diffRow) { |
| 250 |
180 |
if row.Kind == rowNotes { |
| 251 |
36 |
r.writeNotesRow(b, row) |
| 252 |
36 |
return |
| 253 |
36 |
} |
| 254 |
|
|
| 255 |
144 |
b.WriteString(`<tr class="ph-row ph-r-`) |
| 256 |
144 |
b.WriteString(string(row.Kind)) |
| 257 |
144 |
if row.Start { |
| 258 |
124 |
b.WriteString(" ph-blk-start") |
| 259 |
124 |
} |
| 260 |
144 |
if row.Block.Heading { |
| 261 |
28 |
b.WriteString(" ph-head") |
| 262 |
28 |
} |
| 263 |
144 |
if row.Region { |
| 264 |
12 |
b.WriteString(" ph-region") |
| 265 |
12 |
} |
| 266 |
144 |
if row.Folded { |
| 267 |
36 |
b.WriteString(" ph-folded") |
| 268 |
36 |
} |
| 269 |
|
// A marker row is the renderer talking, not the document: "paragraph moved |
| 270 |
|
// here (was line 3)" sits in the same column as the prose around it, and |
| 271 |
|
// without a class of its own it reads as a sentence someone wrote. |
| 272 |
144 |
if row.Note != "" { |
| 273 |
0 |
b.WriteString(" ph-marker") |
| 274 |
0 |
} |
| 275 |
144 |
b.WriteString(`"`) |
| 276 |
144 |
// The id goes on the block's first row and only there: an id repeated down a |
| 277 |
144 |
// block would give one anchor several places to scroll to, and an empty id="" |
| 278 |
144 |
// on an uncommentable row is a fragment that matches every such row at once. |
| 279 |
144 |
if row.Start && row.Block.Commentable { |
| 280 |
124 |
b.WriteString(` id="` + blockDOMID(row.Block.Anchor) + `"`) |
| 281 |
124 |
} |
| 282 |
144 |
writeBlockAttrs(b, row.Block) |
| 283 |
144 |
b.WriteString(`>`) |
| 284 |
144 |
|
| 285 |
144 |
writeNumCell(b, "ph-n-old", row.OldNum, row.OldEnd) |
| 286 |
144 |
writeNumCell(b, "ph-n-new", row.NewNum, row.NewEnd) |
| 287 |
144 |
b.WriteString(`<td class="ph-sign">` + signOf(row.Kind) + `</td>`) |
| 288 |
144 |
|
| 289 |
144 |
b.WriteString(`<td class="ph-text`) |
| 290 |
144 |
if row.Block.Mono { |
| 291 |
0 |
b.WriteString(" ph-mono") |
| 292 |
0 |
} |
| 293 |
144 |
b.WriteString(`">`) |
| 294 |
144 |
if row.Note != "" { |
| 295 |
0 |
// Renderer-authored text, but escaped all the same: a block's label |
| 296 |
0 |
// carries a code fence's info string, which the document wrote. |
| 297 |
0 |
b.WriteString(template.HTMLEscapeString(row.Note)) |
| 298 |
144 |
} else { |
| 299 |
144 |
writeInlineSpans(b, row.Spans) |
| 300 |
144 |
} |
| 301 |
144 |
b.WriteString(`</td></tr>`) |
| 302 |
|
} |
| 303 |
|
|
| 304 |
|
// writeNotesRow renders a block's threads and, for the owner, the form that |
| 305 |
|
// opens a new one, in a full-width row under the block's lines. |
| 306 |
|
// |
| 307 |
|
// It renders through html/template rather than by hand because everything here |
| 308 |
|
// is prose someone else wrote; contextual auto-escaping is what keeps a comment |
| 309 |
|
// body text. The template is executed into a buffer first, for the reason |
| 310 |
|
// Server.render uses one: a template that fails halfway must not leave its |
| 311 |
|
// half-written markup inside the diff. |
| 312 |
|
// |
| 313 |
|
// This is also where a block claims its threads. The row model only emits a |
| 314 |
|
// notes row for a block that has something to put in it, so there is no case |
| 315 |
|
// here for an empty one. |
| 316 |
36 |
func (r *docRenderer) writeNotesRow(b *strings.Builder, row diffRow) { |
| 317 |
36 |
blk := row.Block |
| 318 |
36 |
threads := r.pending[blk.Key] |
| 319 |
36 |
delete(r.pending, blk.Key) |
| 320 |
36 |
|
| 321 |
36 |
// The anchor note states what a comment written here will attach to. The |
| 322 |
36 |
// selection a reviewer makes is by line and the anchor stored is by block, so |
| 323 |
36 |
// without this the indirection would be invisible — and it is server-rendered |
| 324 |
36 |
// rather than filled in by script, because it has to be readable before the |
| 325 |
36 |
// reviewer decides to type. |
| 326 |
36 |
path, index := anchorPathLabel(blk.Anchor.HeadingPath), blk.Anchor.Index |
| 327 |
36 |
|
| 328 |
36 |
data := blockComments{} |
| 329 |
36 |
for _, t := range threads { |
| 330 |
5 |
p := threadPanelOf(t, r.in.Controls) |
| 331 |
5 |
p.AnchorPath, p.AnchorIndex = path, index |
| 332 |
5 |
data.Threads = append(data.Threads, p) |
| 333 |
5 |
} |
| 334 |
36 |
if r.in.Controls.Owner { |
| 335 |
34 |
data.Compose = &composeForm{ |
| 336 |
34 |
ActionBase: r.in.Controls.ActionBase, |
| 337 |
34 |
DocPath: r.in.Path, |
| 338 |
34 |
Ordinal: blk.Key.ordinal, |
| 339 |
34 |
Side: blk.Key.side, |
| 340 |
34 |
Hash: blk.Anchor.BlockHash, |
| 341 |
34 |
AnchorPath: path, |
| 342 |
34 |
AnchorIndex: index, |
| 343 |
34 |
} |
| 344 |
34 |
} |
| 345 |
|
|
| 346 |
36 |
var buf bytes.Buffer |
| 347 |
36 |
if err := blockThreadsTmpl.Execute(&buf, data); err != nil { |
| 348 |
0 |
slog.Error("rendering the comments on a diff block failed", |
| 349 |
0 |
"doc", r.in.Path, scribe.Err(err)) |
| 350 |
0 |
return |
| 351 |
0 |
} |
| 352 |
36 |
b.WriteString(`<tr class="ph-notes`) |
| 353 |
36 |
// A notes row hides with the block it belongs to. Dropping the flag the row |
| 354 |
36 |
// model already set left a collapsed run displaying the compose forms of the |
| 355 |
36 |
// very blocks it had just hidden. |
| 356 |
36 |
if row.Folded { |
| 357 |
9 |
b.WriteString(" ph-folded") |
| 358 |
9 |
} |
| 359 |
36 |
b.WriteString(`"`) |
| 360 |
36 |
writeBlockAttrs(b, blk) |
| 361 |
36 |
b.WriteString(`><td colspan="4">`) |
| 362 |
36 |
b.Write(buf.Bytes()) |
| 363 |
36 |
b.WriteString(`</td></tr>`) |
| 364 |
|
} |
| 365 |
|
|
| 366 |
|
// writeBlockAttrs writes the one data attribute a row of a commentable block |
| 367 |
|
// carries: which block it belongs to. |
| 368 |
|
// |
| 369 |
|
// It used to write the block's heading path and index alongside, so that a |
| 370 |
|
// script could read a row's section without walking back up the table. No |
| 371 |
|
// script reads them, and an attribute pair repeated on every row of every diff |
| 372 |
|
// for a reader that does not exist is the speculative chrome this port set out |
| 373 |
|
// to remove. The heading path a person sees is in the composer and the thread |
| 374 |
|
// header, where it is read. |
| 375 |
|
// |
| 376 |
|
// A row with no anchor gets no attribute at all rather than an empty one. The |
| 377 |
|
// selection script clamps a drag to rows sharing a data-anchor, and rows that |
| 378 |
|
// all carried data-anchor="" would look to it like one enormous block. |
| 379 |
180 |
func writeBlockAttrs(b *strings.Builder, blk *rowBlock) { |
| 380 |
180 |
if !blk.Commentable { |
| 381 |
0 |
return |
| 382 |
0 |
} |
| 383 |
180 |
b.WriteString(` data-anchor="` + blockDOMID(blk.Anchor) + `"`) |
| 384 |
|
} |
| 385 |
|
|
| 386 |
|
// writeNumCell writes one of the two gutter tracks. |
| 387 |
|
// |
| 388 |
|
// A zero number is an empty cell: the row has no line number on this side, and |
| 389 |
|
// the design's rule is that a number the renderer had to guess is never shown. |
| 390 |
|
// A region row states the range it stands for instead, in the cell and in a |
| 391 |
|
// title so it is readable when the track is too narrow for it. |
| 392 |
288 |
func writeNumCell(b *strings.Builder, side string, n, end int) { |
| 393 |
288 |
b.WriteString(`<td class="ph-n ` + side + `"`) |
| 394 |
288 |
switch { |
| 395 |
50 |
case n == 0: |
| 396 |
50 |
b.WriteString(`></td>`) |
| 397 |
0 |
case end > n: |
| 398 |
0 |
fmt.Fprintf(b, ` title="lines %d–%d">%d–%d</td>`, n, end, n, end) |
| 399 |
238 |
default: |
| 400 |
238 |
fmt.Fprintf(b, `>%d</td>`, n) |
| 401 |
|
} |
| 402 |
|
} |
| 403 |
|
|
| 404 |
|
// signOf is the one character the sign column shows. It is where the add/delete |
| 405 |
|
// tint starts, so the gutter never reads as part of the change. |
| 406 |
144 |
func signOf(kind rowKind) string { |
| 407 |
144 |
switch kind { |
| 408 |
27 |
case rowInsert: |
| 409 |
27 |
return "+" |
| 410 |
23 |
case rowDelete: |
| 411 |
23 |
return "-" |
| 412 |
0 |
case rowMove: |
| 413 |
0 |
return "≡" |
| 414 |
|
} |
| 415 |
94 |
return "" |
| 416 |
|
} |
| 417 |
|
|
| 418 |
|
// blockAnchors numbers a revision's blocks the way the anchor model does: |
| 419 |
|
// within their own heading path, not document-globally. core.AnchorBlocks is |
| 420 |
|
// the single spelling of that numbering — service.AnchorOf reproduces it for |
| 421 |
|
// the anchor a comment stores — so the id a block carries here and the anchor |
| 422 |
|
// the form posts back cannot drift apart. |
| 423 |
56 |
func blockAnchors(blocks []prosediff.Block) []core.AnchorBlock { |
| 424 |
56 |
hashes := make([]string, len(blocks)) |
| 425 |
56 |
paths := make([][]string, len(blocks)) |
| 426 |
246 |
for i, b := range blocks { |
| 427 |
246 |
hashes[i], paths[i] = b.Hash, b.HeadingPath |
| 428 |
246 |
} |
| 429 |
56 |
return core.AnchorBlocks(hashes, paths) |
| 430 |
|
} |
| 431 |
|
|
| 432 |
|
// blockDOMID is the id attribute a rendered block carries: a digest of its |
| 433 |
|
// anchor tuple. |
| 434 |
|
// |
| 435 |
|
// Not its position on the page. An ordinal id renumbers whenever anything above |
| 436 |
|
// it is inserted, so a link saved from one revision would silently scroll to a |
| 437 |
|
// different paragraph in the next — exactly the relocation the anchor model |
| 438 |
|
// refuses to do for comments, and the reader could not tell it had happened. |
| 439 |
|
// The digest covers the whole tuple, block hash included, so a link into a block |
| 440 |
|
// that has since been rewritten resolves to nothing at all rather than to its |
| 441 |
|
// neighbour. The index keeps two blocks that repeat the same text under the same |
| 442 |
|
// headings — "TBD" is written a dozen times in a real corpus — from sharing an |
| 443 |
|
// id. |
| 444 |
304 |
func blockDOMID(a core.CommentAnchor) string { |
| 445 |
304 |
h := sha256.New() |
| 446 |
304 |
// NUL separates the parts because it cannot occur in a heading, a path or a |
| 447 |
304 |
// hash, so no two distinct tuples can hash the same byte string. |
| 448 |
1469 |
write := func(s string) { |
| 449 |
1469 |
h.Write([]byte(s)) |
| 450 |
1469 |
h.Write([]byte{0}) |
| 451 |
1469 |
} |
| 452 |
304 |
write(a.DocID) |
| 453 |
304 |
write(string(a.Side)) |
| 454 |
304 |
for _, seg := range a.HeadingPath { |
| 455 |
253 |
write(seg) |
| 456 |
253 |
} |
| 457 |
304 |
write(fmt.Sprint(a.Index)) |
| 458 |
304 |
write(a.BlockHash) |
| 459 |
304 |
return "b-" + hex.EncodeToString(h.Sum(nil))[:16] |
| 460 |
|
} |
| 461 |
|
|
| 462 |
|
// sideOf defaults an empty side to the new one, the way service.CommentOn does |
| 463 |
|
// when it stores a comment: an anchor read back without a side is a comment on |
| 464 |
|
// the text under review. |
| 465 |
14 |
func sideOf(s core.CommentSide) core.CommentSide { |
| 466 |
14 |
if s == core.SideOld { |
| 467 |
2 |
return core.SideOld |
| 468 |
2 |
} |
| 469 |
12 |
return core.SideNew |
| 470 |
|
} |
| 471 |
|
|
| 472 |
|
// writeInlineSpans renders a row's content, marking deletions and insertions |
| 473 |
|
// where they sit. The Space flag reproduces prosediff's own spacing: a span |
| 474 |
|
// that replaces the one before it carries Space=false, so a one-word |
| 475 |
|
// substitution does not render with a gap in the middle. |
| 476 |
144 |
func writeInlineSpans(b *strings.Builder, spans []prosediff.Span) { |
| 477 |
144 |
emitted := false |
| 478 |
200 |
for _, s := range spans { |
| 479 |
200 |
if s.Space && emitted { |
| 480 |
36 |
b.WriteByte(' ') |
| 481 |
36 |
} |
| 482 |
200 |
emitted = true |
| 483 |
200 |
writeSpan(b, s) |
| 484 |
|
} |
| 485 |
|
} |
| 486 |
|
|
| 487 |
|
// writeSpan writes one span's escaped text, wrapped in <del> or <ins> for a |
| 488 |
|
// change and bare for an equal run. |
| 489 |
200 |
func writeSpan(b *strings.Builder, s prosediff.Span) { |
| 490 |
200 |
esc := template.HTMLEscapeString(s.Text) |
| 491 |
200 |
switch s.Op { |
| 492 |
13 |
case prosediff.OpDelete: |
| 493 |
13 |
b.WriteString("<del>") |
| 494 |
13 |
b.WriteString(esc) |
| 495 |
13 |
b.WriteString("</del>") |
| 496 |
23 |
case prosediff.OpInsert: |
| 497 |
23 |
b.WriteString("<ins>") |
| 498 |
23 |
b.WriteString(esc) |
| 499 |
23 |
b.WriteString("</ins>") |
| 500 |
164 |
default: |
| 501 |
164 |
b.WriteString(esc) |
| 502 |
|
} |
| 503 |
|
} |