Files
MindSift/internal/collab/ops.go
T
sandrodachandClaude Opus 5.5 aa397c18cd
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
feat(notes): show block locks and presence, conflict copies with accept/discard, undo/redo
Phase 3 of the collaboration plan:
- Blocks held by someone else show a lock with the name and are read-only;
  Enter in such a block adds a new block below. Delete, merge, indent and
  drag are not started when a involved block (or one in its branch) is
  locked; a short notice explains why (fixes FLAW-25).
- Presence: the editor header lists who else has the note open.
- Conflict copies are highlighted ("Konflikt – Version von …") with
  Übernehmen (replaces the original or the title) and Verwerfen. Both check
  the locks of the original and of the copy first.
- Undo/redo of own changes (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y, beforeinput
  history events): typing is grouped per block until a 1 s pause, up to 200
  steps per tab; a step is skipped with a notice if someone else changed or
  holds the block meanwhile. New clearConflict props patch on the server.
- New blocks get focus synchronously on mount (fixes FLAW-26).
- Fix: after reopening a note from local storage the engine polled every
  30 s instead of 3 s until the next change, because the delay was decided
  before the first response told it the note is shared.
- Tests: undo/conflict engine tests, lock/undo/conflict UI tests and
  e2e/lock.e2e.mjs (two browsers); all three browser tests pass against a
  freshly built image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:44:27 +01:00

402 lines
12 KiB
Go

package collab
import (
"crypto/sha256"
"encoding/hex"
"sort"
)
const (
OpInsert = "insert"
OpSetContent = "set_content"
OpSetProps = "set_props"
OpMove = "move"
OpDelete = "delete"
OpRestore = "restore"
OpSetTitle = "set_title"
StatusApplied = "applied"
StatusConflict = "conflict"
StatusRejected = "rejected"
maxContentLength = 100_000
maxTypeLength = 20
)
// PropsPatch changes selected block settings; nil fields stay as they are.
type PropsPatch struct {
Type *string `json:"type,omitempty"`
Checked *bool `json:"checked,omitempty"`
Collapsed *bool `json:"collapsed,omitempty"`
Lang *string `json:"lang,omitempty"`
// ClearConflict removes the conflict marking (after the user accepted or kept a conflict copy)
ClearConflict *bool `json:"clearConflict,omitempty"`
}
// Op is a change requested by a client. OpID is chosen by the client and makes retries idempotent.
type Op struct {
OpID string `json:"opId"`
Kind string `json:"kind"`
BlockID string `json:"blockId,omitempty"`
BaseVersion int `json:"baseVersion,omitempty"` // set_content, set_title
ParentID *string `json:"parentId,omitempty"` // insert, move ("" = top level)
OrderKey string `json:"orderKey,omitempty"` // insert, move
Type string `json:"type,omitempty"` // insert
Content *string `json:"content,omitempty"` // insert, set_content
Props *Props `json:"props,omitempty"` // insert
Patch *PropsPatch `json:"patch,omitempty"` // set_props
IDs []string `json:"ids,omitempty"` // restore
Title *string `json:"title,omitempty"` // set_title
}
// Effect is a normalized, already validated change. The server log stores effects; replaying them in
// order on an empty Doc reproduces the server state exactly.
type Effect struct {
Kind string `json:"kind"`
BlockID string `json:"blockId,omitempty"`
ParentID string `json:"parentId,omitempty"`
OrderKey string `json:"orderKey,omitempty"`
Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"`
Props *Props `json:"props,omitempty"`
Version int `json:"version,omitempty"`
IDs []string `json:"ids,omitempty"`
Title string `json:"title,omitempty"`
TitleVersion int `json:"titleVersion,omitempty"`
By string `json:"by,omitempty"`
}
type Result struct {
OpID string `json:"opId"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Version int `json:"version,omitempty"`
ConflictBlockID string `json:"conflictBlockId,omitempty"`
Seq int64 `json:"seq,omitempty"`
}
// Actor identifies who sends an operation. ClientID is one browser tab / sync engine instance.
type Actor struct {
UserID string
ClientID string
Name string
}
// LeaseView answers which client currently holds the lease on a block.
type LeaseView interface {
HolderOf(blockID string) (clientID string, held bool)
}
// Env provides outside knowledge needed to validate operations.
type Env struct {
Leases LeaseView // nil = no leases (e.g. server-internal writes)
IDTaken func(id string) bool // true if the block ID exists in another document
}
// ApplyEffect mutates the document with one effect. It never fails; effects are validated by Apply.
func (d *Doc) ApplyEffect(e Effect) {
switch e.Kind {
case OpInsert:
b := &Block{ID: e.BlockID, ParentID: e.ParentID, OrderKey: e.OrderKey, Type: e.Type, Content: e.Content, Version: e.Version, UpdatedBy: e.By}
if e.Props != nil {
b.Props = *e.Props
}
d.Blocks[e.BlockID] = b
case OpSetContent:
if b, ok := d.Blocks[e.BlockID]; ok {
b.Content, b.Version, b.UpdatedBy = e.Content, e.Version, e.By
}
case OpSetProps:
if b, ok := d.Blocks[e.BlockID]; ok {
b.Type = e.Type
if e.Props != nil {
b.Props = *e.Props
}
}
case OpMove:
if b, ok := d.Blocks[e.BlockID]; ok {
b.ParentID, b.OrderKey = e.ParentID, e.OrderKey
}
case OpDelete:
for _, id := range e.IDs {
if b, ok := d.Blocks[id]; ok {
b.Deleted = true
}
}
case OpRestore:
for _, id := range e.IDs {
if b, ok := d.Blocks[id]; ok {
b.Deleted = false
}
}
case OpSetTitle:
d.Title, d.TitleVersion = e.Title, e.TitleVersion
}
}
// ConflictBlockID derives the ID of a conflict copy from the losing operation, so retries never duplicate it.
func ConflictBlockID(opID string) string {
sum := sha256.Sum256([]byte("conflict:" + opID))
return "blk_cf_" + hex.EncodeToString(sum[:8])
}
type applier struct {
d *Doc
a Actor
env Env
effects []Effect
}
func (ap *applier) emit(e Effect) {
e.By = ap.a.UserID
ap.d.ApplyEffect(e)
ap.effects = append(ap.effects, e)
}
func (ap *applier) foreignLease(blockID string) bool {
if ap.env.Leases == nil {
return false
}
holder, held := ap.env.Leases.HolderOf(blockID)
return held && holder != ap.a.ClientID
}
func rejected(op Op, reason string) Result {
return Result{OpID: op.OpID, Status: StatusRejected, Reason: reason}
}
// Apply validates op against d, mutates d and returns the produced effects. Rejected operations
// produce no effects; conflicting text is kept as a separate conflict block.
func Apply(d *Doc, op Op, a Actor, env Env) ([]Effect, Result) {
ap := &applier{d: d, a: a, env: env}
res := ap.apply(op)
res.OpID = op.OpID
return ap.effects, res
}
func (ap *applier) apply(op Op) Result {
d := ap.d
switch op.Kind {
case OpInsert:
if op.BlockID == "" || len(op.BlockID) > 64 {
return rejected(op, "invalid_id")
}
if _, exists := d.Blocks[op.BlockID]; exists || (ap.env.IDTaken != nil && ap.env.IDTaken(op.BlockID)) {
return rejected(op, "exists")
}
parent := ""
if op.ParentID != nil {
parent = *op.ParentID
}
if parent != "" {
if _, ok := d.Blocks[parent]; !ok {
return rejected(op, "parent_missing")
}
parent = d.liveAncestor(parent)
}
if !ValidOrderKey(op.OrderKey) {
return rejected(op, "invalid_order_key")
}
typ := op.Type
if typ == "" {
typ = "bullet"
}
if len(typ) > maxTypeLength {
return rejected(op, "invalid_type")
}
content := ""
if op.Content != nil {
content = *op.Content
}
if len(content) > maxContentLength {
return rejected(op, "too_long")
}
props := Props{}
if op.Props != nil {
props = *op.Props
}
ap.emit(Effect{Kind: OpInsert, BlockID: op.BlockID, ParentID: parent, OrderKey: op.OrderKey, Type: typ, Content: content, Props: &props, Version: 1})
return Result{Status: StatusApplied, Version: 1}
case OpSetContent:
b, ok := d.Blocks[op.BlockID]
if !ok {
return rejected(op, "missing")
}
if op.Content == nil {
return rejected(op, "no_content")
}
if len(*op.Content) > maxContentLength {
return rejected(op, "too_long")
}
if b.Deleted {
// A late edit on a deleted block brings it back instead of losing the text
ap.restore([]string{b.ID})
}
if *op.Content == b.Content {
return Result{Status: StatusApplied, Version: b.Version}
}
if ap.foreignLease(b.ID) || op.BaseVersion != b.Version {
return ap.conflictCopy(op, b, *op.Content)
}
ap.emit(Effect{Kind: OpSetContent, BlockID: b.ID, Content: *op.Content, Version: b.Version + 1})
return Result{Status: StatusApplied, Version: b.Version}
case OpSetProps:
b, ok := d.Blocks[op.BlockID]
if !ok || b.Deleted {
return rejected(op, "missing")
}
if op.Patch == nil {
return rejected(op, "no_patch")
}
typ, props := b.Type, b.Props
if p := op.Patch; p != nil {
if p.Type != nil && *p.Type != "" && len(*p.Type) <= maxTypeLength {
typ = *p.Type
}
if p.Checked != nil {
props.Checked = *p.Checked
}
if p.Collapsed != nil {
props.Collapsed = *p.Collapsed
}
if p.Lang != nil {
props.Lang = *p.Lang
}
if p.ClearConflict != nil && *p.ClearConflict {
props.ConflictOf, props.ConflictAuthor = "", ""
}
}
if typ != b.Type || props != b.Props {
ap.emit(Effect{Kind: OpSetProps, BlockID: b.ID, Type: typ, Props: &props})
}
return Result{Status: StatusApplied}
case OpMove:
b, ok := d.Blocks[op.BlockID]
if !ok || b.Deleted {
return rejected(op, "missing")
}
if ap.foreignLease(b.ID) {
return rejected(op, "locked")
}
parent := ""
if op.ParentID != nil {
parent = *op.ParentID
}
if parent != "" {
p, ok := d.Blocks[parent]
if !ok || p.Deleted {
return rejected(op, "parent_missing")
}
if d.isAncestor(b.ID, parent) {
return rejected(op, "cycle")
}
}
if !ValidOrderKey(op.OrderKey) {
return rejected(op, "invalid_order_key")
}
if parent != b.ParentID || op.OrderKey != b.OrderKey {
ap.emit(Effect{Kind: OpMove, BlockID: b.ID, ParentID: parent, OrderKey: op.OrderKey})
}
return Result{Status: StatusApplied}
case OpDelete:
b, ok := d.Blocks[op.BlockID]
if !ok {
return rejected(op, "missing")
}
if b.Deleted {
return Result{Status: StatusApplied}
}
ids := d.subtree(b.ID, true)
for _, id := range ids {
if ap.foreignLease(id) {
return rejected(op, "locked")
}
}
ap.emit(Effect{Kind: OpDelete, IDs: ids})
return Result{Status: StatusApplied}
case OpRestore:
ids := make([]string, 0, len(op.IDs))
for _, id := range op.IDs {
if b, ok := d.Blocks[id]; ok && b.Deleted {
ids = append(ids, id)
}
}
if len(ids) > 0 {
ap.restore(ids)
}
return Result{Status: StatusApplied}
case OpSetTitle:
if op.Title == nil {
return rejected(op, "no_title")
}
if len(*op.Title) > 1000 {
return rejected(op, "too_long")
}
if *op.Title == d.Title {
return Result{Status: StatusApplied, Version: d.TitleVersion}
}
if op.BaseVersion != d.TitleVersion {
return ap.titleConflict(op, *op.Title)
}
ap.emit(Effect{Kind: OpSetTitle, Title: *op.Title, TitleVersion: d.TitleVersion + 1})
return Result{Status: StatusApplied, Version: d.TitleVersion}
}
return rejected(op, "unknown_kind")
}
// restore brings back deleted blocks; blocks whose parent stays deleted move to the nearest live ancestor.
func (ap *applier) restore(ids []string) {
d := ap.d
set := make(map[string]bool, len(ids))
for _, id := range ids {
set[id] = true
}
sorted := append([]string(nil), ids...)
sort.Strings(sorted)
ap.emit(Effect{Kind: OpRestore, IDs: sorted})
for _, id := range sorted {
b := d.Blocks[id]
if b.ParentID == "" || set[b.ParentID] {
continue
}
if p, ok := d.Blocks[b.ParentID]; ok && !p.Deleted {
continue
}
ap.emit(Effect{Kind: OpMove, BlockID: b.ID, ParentID: d.liveAncestor(b.ParentID), OrderKey: b.OrderKey})
}
}
// conflictCopy keeps the losing text as a new block directly below the contested one.
func (ap *applier) conflictCopy(op Op, b *Block, content string) Result {
id := ConflictBlockID(op.OpID)
if _, exists := ap.d.Blocks[id]; !exists {
props := Props{ConflictOf: b.ID, ConflictAuthor: ap.a.Name}
ap.emit(Effect{Kind: OpInsert, BlockID: id, ParentID: b.ParentID, OrderKey: ap.d.keyAfter(b), Type: b.Type, Content: content, Props: &props, Version: 1})
}
return Result{Status: StatusConflict, Version: b.Version, ConflictBlockID: id}
}
// titleConflict keeps a losing title as a conflict block at the very top of the note.
func (ap *applier) titleConflict(op Op, title string) Result {
id := ConflictBlockID(op.OpID)
if _, exists := ap.d.Blocks[id]; !exists {
first := ""
if roots := ap.d.Children(""); len(roots) > 0 {
first = roots[0].OrderKey
}
key, err := KeyBetween("", first)
if err != nil {
key, _ = KeyBetween("", "")
}
props := Props{ConflictOf: "title", ConflictAuthor: ap.a.Name}
ap.emit(Effect{Kind: OpInsert, BlockID: id, OrderKey: key, Type: "text", Content: title, Props: &props, Version: 1})
}
return Result{Status: StatusConflict, Version: ap.d.TitleVersion, ConflictBlockID: id}
}