| 1 |
|
package core |
| 2 |
|
|
| 3 |
|
import "fmt" |
| 4 |
|
|
| 5 |
|
// CommentSide is which revision of a changed block a comment hangs off. |
| 6 |
|
// |
| 7 |
|
// A modified block exists twice — once as it was approved, once as proposed — |
| 8 |
|
// and the two have different content, so "this sentence is wrong" has to say |
| 9 |
|
// which sentence. Comments go on the new side, because that is the text under |
| 10 |
|
// review; the old side exists for a block the proposal deletes outright, where |
| 11 |
|
// there is no new text to point at. |
| 12 |
|
type CommentSide string |
| 13 |
|
|
| 14 |
|
const ( |
| 15 |
|
// SideNew anchors to the proposed revision of a block. |
| 16 |
|
SideNew CommentSide = "new" |
| 17 |
|
// SideOld anchors to the approved revision, for a deleted block. |
| 18 |
|
SideOld CommentSide = "old" |
| 19 |
|
) |
| 20 |
|
|
| 21 |
|
// ParseCommentSide validates a side read back from Postgres or an API request. |
| 22 |
3 |
func ParseCommentSide(s string) (CommentSide, error) { |
| 23 |
3 |
switch CommentSide(s) { |
| 24 |
2 |
case SideNew, SideOld: |
| 25 |
2 |
return CommentSide(s), nil |
| 26 |
|
} |
| 27 |
1 |
return "", fmt.Errorf("%w: %q is not one of new|old", ErrInvalidCommentSide, s) |
| 28 |
|
} |
| 29 |
|
|
| 30 |
|
// CommentAnchor is where a comment is attached: one block of one document, |
| 31 |
|
// named by what it says rather than by where it sits. |
| 32 |
|
// |
| 33 |
|
// Line numbers are useless here. Markdown reflows, so a one-word edit rewraps a |
| 34 |
|
// paragraph and every line number below it moves; that is the same property |
| 35 |
|
// that made a line-oriented differ unusable for prose and it makes a |
| 36 |
|
// line-oriented comment anchor unusable for the same reason. |
| 37 |
|
// |
| 38 |
|
// The tuple is the one prosediff already emits for every block, which is why |
| 39 |
|
// the anchoring model could be validated before it was committed to a schema. |
| 40 |
|
type CommentAnchor struct { |
| 41 |
|
// DocID is the archive's addressing key for the document — its frontmatter |
| 42 |
|
// id when it has a unique well-formed one, otherwise its path. Anchoring on |
| 43 |
|
// the id rather than the path is what makes a comment survive a rename. |
| 44 |
|
DocID string |
| 45 |
|
// HeadingPath is the enclosing headings, outermost first. |
| 46 |
|
// |
| 47 |
|
// It is deliberately not part of BlockHash: renaming a section would |
| 48 |
|
// otherwise change the hash of every block beneath it and orphan every |
| 49 |
|
// comment in that section at once, which is the failure mode most likely to |
| 50 |
|
// happen on a real editing pass. |
| 51 |
|
HeadingPath []string |
| 52 |
|
// Index is the block's position among the blocks sharing its HeadingPath, |
| 53 |
|
// 0-based — not its position in the document. |
| 54 |
|
// |
| 55 |
|
// prosediff numbers blocks document-globally, and that number is what a |
| 56 |
|
// caller has in hand; it is converted here because a document-global index |
| 57 |
|
// is destroyed by any insertion above it. Since the fallback exists |
| 58 |
|
// precisely for the case where the block's content changed, an index that |
| 59 |
|
// every unrelated edit invalidates would leave the fallback unable to fire |
| 60 |
|
// exactly when it is needed. |
| 61 |
|
Index int |
| 62 |
|
// BlockHash is prosediff's structure-plus-normalized-content hash of the |
| 63 |
|
// block as it read when the comment was written. |
| 64 |
|
BlockHash string |
| 65 |
|
// Side is which revision of the block was commented on. |
| 66 |
|
Side CommentSide |
| 67 |
|
} |
| 68 |
|
|
| 69 |
|
// AnchorBlock is the part of a segmented block that anchoring reads. |
| 70 |
|
// |
| 71 |
|
// It exists so that core does not import prosediff. Dependency direction is |
| 72 |
|
// strictly downward and the comment anchoring rules are domain logic, not diff |
| 73 |
|
// logic — a caller passes prosediff's blocks through this shape. |
| 74 |
|
type AnchorBlock struct { |
| 75 |
|
HeadingPath []string |
| 76 |
|
// Index is the block's position among blocks sharing HeadingPath, 0-based. |
| 77 |
|
// Build a slice of these with [AnchorBlocks] rather than filling it by hand. |
| 78 |
|
Index int |
| 79 |
|
Hash string |
| 80 |
|
} |
| 81 |
|
|
| 82 |
|
// AnchorState is how well a comment's anchor still describes the revision being |
| 83 |
|
// looked at. It is derived, never stored: a comment is not outdated in general, |
| 84 |
|
// it is outdated *at a revision*, and a proposal branch moves under it as the |
| 85 |
|
// agent revises. |
| 86 |
|
type AnchorState string |
| 87 |
|
|
| 88 |
|
const ( |
| 89 |
|
// AnchorExact means the commented block is still present verbatim. |
| 90 |
|
AnchorExact AnchorState = "anchored" |
| 91 |
|
// AnchorEdited means the block at the anchor's position is still there but |
| 92 |
|
// its text has changed since the comment was written. The comment is shown |
| 93 |
|
// against it, marked, so the reader can see the critique may no longer fit. |
| 94 |
|
AnchorEdited AnchorState = "edited" |
| 95 |
|
// AnchorOutdated means neither the content nor the position matched. |
| 96 |
|
// |
| 97 |
|
// The comment is kept and reported as outdated rather than relocated to a |
| 98 |
|
// best guess. A comment moved to the wrong paragraph is worse than one |
| 99 |
|
// admitting it lost its place: the reader cannot tell it is wrong. |
| 100 |
|
AnchorOutdated AnchorState = "outdated" |
| 101 |
|
) |
| 102 |
|
|
| 103 |
|
// AnchorBlocks converts a revision's blocks, in document order, into the shape |
| 104 |
|
// [ResolveAnchor] reads — numbering each block within its own heading path. |
| 105 |
|
// |
| 106 |
|
// hashes and paths are parallel slices in document order, which is what a |
| 107 |
|
// caller holds after segmenting: pass block.Hash and block.HeadingPath. |
| 108 |
9 |
func AnchorBlocks(hashes []string, paths [][]string) []AnchorBlock { |
| 109 |
9 |
out := make([]AnchorBlock, len(hashes)) |
| 110 |
9 |
seen := make(map[string]int, len(hashes)) |
| 111 |
31 |
for i, h := range hashes { |
| 112 |
31 |
var p []string |
| 113 |
31 |
if i < len(paths) { |
| 114 |
31 |
p = paths[i] |
| 115 |
31 |
} |
| 116 |
31 |
key := headingKey(p) |
| 117 |
31 |
out[i] = AnchorBlock{HeadingPath: p, Index: seen[key], Hash: h} |
| 118 |
31 |
seen[key]++ |
| 119 |
|
} |
| 120 |
9 |
return out |
| 121 |
|
} |
| 122 |
|
|
| 123 |
|
// ResolveAnchor locates a comment's block in a revision, returning the index |
| 124 |
|
// into blocks and how confident that answer is. The index is -1 when the anchor |
| 125 |
|
// did not resolve. |
| 126 |
|
// |
| 127 |
|
// The order is content first, position second, give up third: |
| 128 |
|
// |
| 129 |
|
// 1. an identical block hash means the commented text is still there, wherever |
| 130 |
|
// it now sits — content is the strongest evidence and survives reflow, |
| 131 |
|
// renumbering and section renames; |
| 132 |
|
// 2. failing that, the block at the same position under the same headings is |
| 133 |
|
// taken to be the same block, edited; |
| 134 |
|
// 3. failing both, the anchor is outdated. |
| 135 |
|
// |
| 136 |
|
// Step 1 can match more than once — a document may repeat a paragraph, and |
| 137 |
|
// "TBD" appears verbatim in a dozen places — so the heading path breaks the tie |
| 138 |
|
// and the nearest index breaks what the heading path does not. Without that, |
| 139 |
|
// which duplicate a comment landed on would depend on document order. |
| 140 |
10 |
func ResolveAnchor(a CommentAnchor, blocks []AnchorBlock) (int, AnchorState) { |
| 141 |
10 |
if best := bestHashMatch(a, blocks); best >= 0 { |
| 142 |
7 |
return best, AnchorExact |
| 143 |
7 |
} |
| 144 |
9 |
for i, b := range blocks { |
| 145 |
9 |
if b.Index == a.Index && headingKey(b.HeadingPath) == headingKey(a.HeadingPath) { |
| 146 |
2 |
return i, AnchorEdited |
| 147 |
2 |
} |
| 148 |
|
} |
| 149 |
1 |
return -1, AnchorOutdated |
| 150 |
|
} |
| 151 |
|
|
| 152 |
|
// bestHashMatch returns the index of the block whose hash equals the anchor's, |
| 153 |
|
// preferring one under the same headings and then the closest index. It returns |
| 154 |
|
// -1 when no block has that hash. |
| 155 |
|
// |
| 156 |
|
// sameSectionBonus dominates any positional term, so a duplicate under the |
| 157 |
|
// comment's own headings always beats a nearer one somewhere else: a comment on |
| 158 |
|
// "TBD" under "Storage" belongs to Storage's TBD even when another section's |
| 159 |
|
// sits at a closer index. |
| 160 |
10 |
func bestHashMatch(a CommentAnchor, blocks []AnchorBlock) int { |
| 161 |
10 |
const sameSectionBonus = 1 << 20 |
| 162 |
10 |
if a.BlockHash == "" { |
| 163 |
0 |
return -1 |
| 164 |
0 |
} |
| 165 |
10 |
want := headingKey(a.HeadingPath) |
| 166 |
10 |
best, bestScore := -1, -1 |
| 167 |
37 |
for i, b := range blocks { |
| 168 |
37 |
if b.Hash != a.BlockHash { |
| 169 |
26 |
continue |
| 170 |
|
} |
| 171 |
11 |
score := -abs(b.Index - a.Index) // nearer is better |
| 172 |
11 |
if headingKey(b.HeadingPath) == want { |
| 173 |
5 |
score += sameSectionBonus |
| 174 |
5 |
} |
| 175 |
11 |
if score > bestScore { |
| 176 |
7 |
best, bestScore = i, score |
| 177 |
7 |
} |
| 178 |
|
} |
| 179 |
10 |
return best |
| 180 |
|
} |
| 181 |
|
|
| 182 |
|
// headingKey flattens a heading path to a comparable string. "\x00" is the |
| 183 |
|
// separator because it cannot occur in a heading, so no two distinct paths |
| 184 |
|
// collide the way " › " would for a heading containing that sequence. |
| 185 |
58 |
func headingKey(path []string) string { |
| 186 |
58 |
key := "" |
| 187 |
58 |
for _, h := range path { |
| 188 |
58 |
key += h + "\x00" |
| 189 |
58 |
} |
| 190 |
58 |
return key |
| 191 |
|
} |
| 192 |
|
|
| 193 |
11 |
func abs(n int) int { |
| 194 |
11 |
if n < 0 { |
| 195 |
2 |
return -n |
| 196 |
2 |
} |
| 197 |
9 |
return n |
| 198 |
|
} |