Files
xarmian 0cc033edd8 feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.

expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).

CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00

69 lines
1.9 KiB
Go

package bodypatch
import (
"fmt"
"strconv"
"strings"
)
// BodyLines is a numbered slice of an item's body (PLAN-3578), for targeting
// and quoting a patch. Line numbers are 1-based over the body as stored,
// split on "\n". They are for reading only: a patch anchors on text.
type BodyLines struct {
Ref string `json:"ref"`
Seq int64 `json:"seq"`
ContentState string `json:"content_state,omitempty"`
Start int `json:"start"`
End int `json:"end"`
TotalLines int `json:"total_lines"`
Lines []BodyLine `json:"lines"`
}
// BodyLine is one numbered line.
type BodyLine struct {
N int `json:"n"`
Text string `json:"text"`
}
// ParseLineRange reads "A-B", "A-" (to the end) or "A". A range past the
// body's end is clipped by SliceBodyLines, not refused here.
func ParseLineRange(s string) (start, end int, err error) {
bad := fmt.Errorf("lines wants A-B, A- or A (1-based), got %q", s)
a, b, isRange := strings.Cut(strings.TrimSpace(s), "-")
start, err = strconv.Atoi(a)
if err != nil || start < 1 {
return 0, 0, bad
}
switch {
case !isRange:
return start, start, nil
case b == "":
return start, 0, nil // 0: to the end
}
end, err = strconv.Atoi(b)
if err != nil || end < start {
return 0, 0, bad
}
return start, end, nil
}
// SliceBodyLines numbers content's lines start..end (end 0 = to the end),
// clipped to the body. A body that ends in "\n" has no empty last line.
func SliceBodyLines(content string, start, end int) (lines []BodyLine, from, to, total int) {
all := strings.Split(strings.TrimSuffix(content, "\n"), "\n")
if content == "" {
all = nil
}
total = len(all)
if end == 0 || end > total {
end = total
}
for n := start; n <= end; n++ {
lines = append(lines, BodyLine{N: n, Text: all[n-1]})
}
if len(lines) == 0 {
lines = []BodyLine{}
}
return lines, start, end, total
}