MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
Phase 1 of the collaboration plan (server side):
- internal/collab: block tree with parent IDs and fractional order keys,
operations with conflict rules (version check, block leases, conflict
copies instead of lost text, restore on late edits, cycle checks),
in-memory leases/sessions and long-poll notifier.
- Storage: per-note lock + transaction, append-only effect log (doc_ops),
idempotent results per opId (doc_op_results), snapshot, log paging,
central DocRole. Old flat block data is converted to the tree.
- The existing whole-document save (PUT/PATCH) runs as an adapter through
the same engine, so the current editor keeps working and writes to the log.
- API: GET /docs/{id}/snapshot, POST /docs/{id}/sync, GET /docs/{id}/changes
(long poll), POST /docs/{id}/leave; DOC_SEQ poke for WebSocket/SSE rooms.
- Backend accepts editor (write) shares for notes and folders; the forced
read-only migration is gone. The share dialog still offers read only.
- Tests: conflict matrix, order keys, leases, storage adapter round trip,
idempotency, API protocol incl. long poll, and a randomized simulation
(4 clients, lost/duplicated requests and responses, offline phases,
lease expiry) checking convergence, no lost text and exactly-once results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
664 lines
22 KiB
Go
664 lines
22 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/sandrodach/mindsift/internal/collab"
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
// Collaborative note editing: every change to a note's blocks or title runs through collab.Apply under a
|
|
// per-document lock, is appended to doc_ops (the log) and recorded in doc_op_results (idempotency).
|
|
|
|
// ErrDocNotFound is returned when a note does not exist or the user has no access to it.
|
|
var ErrDocNotFound = errors.New("Notiz nicht gefunden oder kein Zugriff")
|
|
|
|
// Roles a user can have on a note.
|
|
const (
|
|
DocRoleOwner = "owner"
|
|
DocRoleEditor = "editor"
|
|
DocRoleViewer = "viewer"
|
|
)
|
|
|
|
// DocLogEntry is one effect from the log, as sent to clients.
|
|
type DocLogEntry struct {
|
|
Seq int64 `json:"seq"`
|
|
OpID string `json:"opId,omitempty"`
|
|
UserID string `json:"userId"`
|
|
ClientID string `json:"clientId"`
|
|
Effect collab.Effect `json:"effect"`
|
|
}
|
|
|
|
// DocSnapshot is the complete state of a note including tombstones, needed to replay later effects.
|
|
type DocSnapshot struct {
|
|
DocID string `json:"docId"`
|
|
Title string `json:"title"`
|
|
TitleVersion int `json:"titleVersion"`
|
|
HeadSeq int64 `json:"headSeq"`
|
|
Blocks []collab.Block `json:"blocks"`
|
|
}
|
|
|
|
var docLocks sync.Map // docID -> *sync.Mutex
|
|
|
|
func lockDoc(docID string) func() {
|
|
m, _ := docLocks.LoadOrStore(docID, &sync.Mutex{})
|
|
mu := m.(*sync.Mutex)
|
|
mu.Lock()
|
|
return mu.Unlock
|
|
}
|
|
|
|
type docQueryer interface {
|
|
QueryRow(query string, args ...interface{}) *sql.Row
|
|
Query(query string, args ...interface{}) (*sql.Rows, error)
|
|
}
|
|
|
|
// migrateDocSync adds the columns and tables of the collaboration model and converts old flat block lists.
|
|
func (s *Store) migrateDocSync() {
|
|
stmts := []string{
|
|
"ALTER TABLE blocks ADD COLUMN parent_id TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE blocks ADD COLUMN order_key TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE blocks ADD COLUMN version INTEGER NOT NULL DEFAULT 1;",
|
|
"ALTER TABLE blocks ADD COLUMN conflict_of TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE blocks ADD COLUMN conflict_author TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE blocks ADD COLUMN updated_by TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE blocks ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;",
|
|
"CREATE INDEX IF NOT EXISTS idx_blocks_doc_parent ON blocks(document_id, parent_id);",
|
|
"ALTER TABLE documents ADD COLUMN head_seq INTEGER NOT NULL DEFAULT 0;",
|
|
"ALTER TABLE documents ADD COLUMN title_version INTEGER NOT NULL DEFAULT 0;",
|
|
`CREATE TABLE IF NOT EXISTS doc_ops (
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
seq INTEGER NOT NULL,
|
|
op_id TEXT NOT NULL DEFAULT '',
|
|
user_id TEXT NOT NULL DEFAULT '',
|
|
client_id TEXT NOT NULL DEFAULT '',
|
|
kind TEXT NOT NULL,
|
|
block_id TEXT NOT NULL DEFAULT '',
|
|
payload TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (doc_id, seq)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS doc_op_results (
|
|
op_id TEXT PRIMARY KEY,
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL DEFAULT '',
|
|
result TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);`,
|
|
"CREATE INDEX IF NOT EXISTS idx_doc_op_results_doc ON doc_op_results(doc_id);",
|
|
}
|
|
for _, q := range stmts {
|
|
if _, err := s.db.Exec(q); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
|
slog.Error("Migration der Notiz-Synchronisation fehlgeschlagen", "error", err, "statement", q)
|
|
}
|
|
}
|
|
s.backfillBlockTree()
|
|
}
|
|
|
|
// backfillBlockTree converts blocks stored as a flat list (level + position) into parent_id + order_key.
|
|
func (s *Store) backfillBlockTree() {
|
|
rows, err := s.db.Query(`SELECT DISTINCT document_id FROM blocks WHERE order_key = ''`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var docIDs []string
|
|
for rows.Next() {
|
|
var id string
|
|
if rows.Scan(&id) == nil {
|
|
docIDs = append(docIDs, id)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
for _, docID := range docIDs {
|
|
bRows, err := s.db.Query(`SELECT id, level FROM blocks WHERE document_id = ? ORDER BY position ASC, rowid ASC`, docID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
type flat struct {
|
|
id string
|
|
level int
|
|
}
|
|
var list []flat
|
|
for bRows.Next() {
|
|
var f flat
|
|
if bRows.Scan(&f.id, &f.level) == nil {
|
|
list = append(list, f)
|
|
}
|
|
}
|
|
_ = bRows.Close()
|
|
|
|
parents, keys := treeFromLevels(len(list), func(i int) int { return list[i].level })
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for i, f := range list {
|
|
parent := ""
|
|
if parents[i] >= 0 {
|
|
parent = list[parents[i]].id
|
|
}
|
|
_, _ = tx.Exec(`UPDATE blocks SET parent_id = ?, order_key = ? WHERE id = ?`, parent, keys[i], f.id)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
slog.Error("Umwandlung der Blockstruktur fehlgeschlagen", "doc_id", docID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// treeFromLevels turns a flat list with nesting levels into parent indices (-1 = top level) and
|
|
// sequential order keys per parent. Levels deeper than possible are clamped.
|
|
func treeFromLevels(n int, level func(int) int) ([]int, []string) {
|
|
parents := make([]int, n)
|
|
keys := make([]string, n)
|
|
lastKey := map[int]string{}
|
|
var stack []int // stack[l] = index of the last block at level l
|
|
for i := 0; i < n; i++ {
|
|
lvl := level(i)
|
|
if lvl < 0 {
|
|
lvl = 0
|
|
}
|
|
if lvl > len(stack) {
|
|
lvl = len(stack)
|
|
}
|
|
stack = stack[:lvl]
|
|
parent := -1
|
|
if lvl > 0 {
|
|
parent = stack[lvl-1]
|
|
}
|
|
stack = append(stack, i)
|
|
parents[i] = parent
|
|
k, _ := collab.KeyBetween(lastKey[parent], "")
|
|
lastKey[parent] = k
|
|
keys[i] = k
|
|
}
|
|
return parents, keys
|
|
}
|
|
|
|
func loadCollabDoc(q docQueryer, docID string) (*collab.Doc, int64, error) {
|
|
d := collab.NewDoc()
|
|
var head int64
|
|
err := q.QueryRow(`SELECT title, title_version, head_seq FROM documents WHERE id = ?`, docID).Scan(&d.Title, &d.TitleVersion, &head)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := q.Query(
|
|
`SELECT id, parent_id, order_key, type, content, checked, collapsed, lang, conflict_of, conflict_author, version, deleted, updated_by
|
|
FROM blocks WHERE document_id = ?`, docID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
b := &collab.Block{}
|
|
var chk, col, del int
|
|
if err := rows.Scan(&b.ID, &b.ParentID, &b.OrderKey, &b.Type, &b.Content, &chk, &col, &b.Props.Lang,
|
|
&b.Props.ConflictOf, &b.Props.ConflictAuthor, &b.Version, &del, &b.UpdatedBy); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
b.Props.Checked, b.Props.Collapsed, b.Deleted = chk != 0, col != 0, del != 0
|
|
d.Blocks[b.ID] = b
|
|
}
|
|
return d, head, rows.Err()
|
|
}
|
|
|
|
// flatDocumentBlocks converts the live tree into the flat list (level + position) the existing API returns.
|
|
func flatDocumentBlocks(docID string, d *collab.Doc) []domain.DocumentBlock {
|
|
flat := d.Flatten()
|
|
out := make([]domain.DocumentBlock, len(flat))
|
|
for i, f := range flat {
|
|
out[i] = domain.DocumentBlock{
|
|
ID: f.ID, DocumentID: docID, Type: f.Type, Content: f.Content, Checked: f.Props.Checked,
|
|
Lang: f.Props.Lang, Level: f.Level, Position: float64((i + 1) * 1000), Collapsed: f.Props.Collapsed,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func boolInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ApplyDocOps applies client operations to a note. Operations whose opId was already processed return
|
|
// the stored result without being applied again, so clients can safely resend after lost responses.
|
|
func (s *Store) ApplyDocOps(docID string, a collab.Actor, ops []collab.Op, leases collab.LeaseView) ([]collab.Result, int64, error) {
|
|
return s.applyDocChange(docID, a, leases, func(*collab.Doc) []collab.Op { return ops })
|
|
}
|
|
|
|
// applyDocChange builds operations from the current state (under the document lock) and applies them.
|
|
func (s *Store) applyDocChange(docID string, a collab.Actor, leases collab.LeaseView, build func(*collab.Doc) []collab.Op) ([]collab.Result, int64, error) {
|
|
unlock := lockDoc(docID)
|
|
defer unlock()
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
doc, head, err := loadCollabDoc(tx, docID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, 0, ErrDocNotFound
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
before := doc.Clone()
|
|
startHead := head
|
|
env := collab.Env{
|
|
Leases: leases,
|
|
IDTaken: func(id string) bool {
|
|
var owner string
|
|
return tx.QueryRow(`SELECT document_id FROM blocks WHERE id = ?`, id).Scan(&owner) == nil && owner != docID
|
|
},
|
|
}
|
|
now := domain.NowISO()
|
|
ops := build(doc)
|
|
results := make([]collab.Result, 0, len(ops))
|
|
|
|
for _, op := range ops {
|
|
if op.OpID == "" || len(op.OpID) > 100 {
|
|
results = append(results, collab.Result{OpID: op.OpID, Status: collab.StatusRejected, Reason: "invalid_op_id"})
|
|
continue
|
|
}
|
|
var stored, storedDoc string
|
|
err := tx.QueryRow(`SELECT result, doc_id FROM doc_op_results WHERE op_id = ?`, op.OpID).Scan(&stored, &storedDoc)
|
|
if err == nil {
|
|
var res collab.Result
|
|
if storedDoc != docID || json.Unmarshal([]byte(stored), &res) != nil {
|
|
res = collab.Result{OpID: op.OpID, Status: collab.StatusRejected, Reason: "op_id_reused"}
|
|
}
|
|
results = append(results, res)
|
|
continue
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, 0, err
|
|
}
|
|
|
|
effects, res := collab.Apply(doc, op, a, env)
|
|
for _, e := range effects {
|
|
head++
|
|
payload, _ := json.Marshal(e)
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO doc_ops (doc_id, seq, op_id, user_id, client_id, kind, block_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
docID, head, op.OpID, a.UserID, a.ClientID, e.Kind, e.BlockID, string(payload), now,
|
|
); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
if len(effects) > 0 {
|
|
res.Seq = head
|
|
}
|
|
resJSON, _ := json.Marshal(res)
|
|
if _, err := tx.Exec(`INSERT INTO doc_op_results (op_id, doc_id, user_id, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
op.OpID, docID, a.UserID, string(resJSON), now); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
results = append(results, res)
|
|
}
|
|
|
|
if head != startHead {
|
|
if err := persistDocState(tx, docID, before, doc, head, now); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return results, head, nil
|
|
}
|
|
|
|
// persistDocState writes changed blocks and refreshes data derived from the note (markdown, search text, tags).
|
|
func persistDocState(tx *sql.Tx, docID string, before, after *collab.Doc, head int64, now string) error {
|
|
stmt, err := tx.Prepare(`
|
|
INSERT INTO blocks (id, document_id, parent_id, order_key, type, content, checked, collapsed, lang,
|
|
conflict_of, conflict_author, version, deleted, updated_by, level, position)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
parent_id = excluded.parent_id, order_key = excluded.order_key, type = excluded.type,
|
|
content = excluded.content, checked = excluded.checked, collapsed = excluded.collapsed,
|
|
lang = excluded.lang, conflict_of = excluded.conflict_of, conflict_author = excluded.conflict_author,
|
|
version = excluded.version, deleted = excluded.deleted, updated_by = excluded.updated_by
|
|
WHERE blocks.document_id = excluded.document_id`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
tagsChanged := false
|
|
for id, b := range after.Blocks {
|
|
if old, ok := before.Blocks[id]; ok && *old == *b {
|
|
continue
|
|
}
|
|
tagsChanged = true
|
|
if _, err := stmt.Exec(b.ID, docID, b.ParentID, b.OrderKey, b.Type, b.Content, boolInt(b.Props.Checked),
|
|
boolInt(b.Props.Collapsed), b.Props.Lang, b.Props.ConflictOf, b.Props.ConflictAuthor, b.Version,
|
|
boolInt(b.Deleted), b.UpdatedBy); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
flat := flatDocumentBlocks(docID, after)
|
|
var plain strings.Builder
|
|
for _, b := range flat {
|
|
if b.Content != "" {
|
|
plain.WriteString(b.Content)
|
|
plain.WriteString("\n")
|
|
}
|
|
}
|
|
blocksJSON, _ := json.Marshal(flat)
|
|
if _, err := tx.Exec(
|
|
`UPDATE documents SET title = ?, title_version = ?, head_seq = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
|
|
after.Title, after.TitleVersion, head, domain.CompileBlocksToMarkdown(flat), plain.String(), string(blocksJSON), now, docID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if tagsChanged {
|
|
if _, err := tx.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID); err != nil {
|
|
return err
|
|
}
|
|
for _, b := range flat {
|
|
for _, tag := range domain.ExtractTags(b.Content) {
|
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`, tag, docID, b.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetDocOpsSince returns up to limit log entries after seq, plus the current head.
|
|
func (s *Store) GetDocOpsSince(docID string, since int64, limit int) ([]DocLogEntry, int64, error) {
|
|
var head int64
|
|
if err := s.db.QueryRow(`SELECT head_seq FROM documents WHERE id = ?`, docID).Scan(&head); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := s.db.Query(
|
|
`SELECT seq, op_id, user_id, client_id, payload FROM doc_ops WHERE doc_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?`,
|
|
docID, since, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]DocLogEntry, 0)
|
|
for rows.Next() {
|
|
var e DocLogEntry
|
|
var payload string
|
|
if err := rows.Scan(&e.Seq, &e.OpID, &e.UserID, &e.ClientID, &payload); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := json.Unmarshal([]byte(payload), &e.Effect); err != nil {
|
|
return nil, 0, fmt.Errorf("log entry %d unreadable: %w", e.Seq, err)
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, head, rows.Err()
|
|
}
|
|
|
|
// GetDocSnapshot returns the full block tree (including tombstones) and the log position it corresponds to.
|
|
func (s *Store) GetDocSnapshot(docID string) (*DocSnapshot, error) {
|
|
unlock := lockDoc(docID)
|
|
defer unlock()
|
|
d, head, err := loadCollabDoc(s.db, docID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrDocNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
snap := &DocSnapshot{DocID: docID, Title: d.Title, TitleVersion: d.TitleVersion, HeadSeq: head, Blocks: make([]collab.Block, 0, len(d.Blocks))}
|
|
for _, b := range d.Blocks {
|
|
snap.Blocks = append(snap.Blocks, *b)
|
|
}
|
|
sort.Slice(snap.Blocks, func(i, j int) bool { return snap.Blocks[i].ID < snap.Blocks[j].ID })
|
|
return snap, nil
|
|
}
|
|
|
|
// DocRole returns the user's role on a note ("owner", "editor", "viewer") or "" without access.
|
|
// Sources: ownership, direct shares, team shares and shares of the shared folder the note lives in.
|
|
func (s *Store) DocRole(userID, docID string) string {
|
|
if userID == "" {
|
|
return ""
|
|
}
|
|
var ownerID, sharedFolderID, folderOwner string
|
|
err := s.db.QueryRow(
|
|
`SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, '')
|
|
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
|
|
WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if ownerID == userID {
|
|
return DocRoleOwner
|
|
}
|
|
if folderOwner == userID {
|
|
return DocRoleEditor
|
|
}
|
|
rows, err := s.db.Query(
|
|
`SELECT permission FROM doc_shares WHERE doc_id = ? AND user_id = ?
|
|
UNION ALL SELECT dts.permission FROM doc_team_shares dts JOIN team_members tm ON tm.team_id = dts.team_id
|
|
WHERE dts.doc_id = ? AND tm.user_id = ?
|
|
UNION ALL SELECT wfs.permission FROM workspace_folder_shares wfs
|
|
WHERE wfs.folder_id = ? AND wfs.folder_id != '' AND (
|
|
(wfs.target_type = 'user' AND wfs.target_id = ?)
|
|
OR (wfs.target_type = 'team' AND wfs.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))`,
|
|
docID, userID, docID, userID, sharedFolderID, userID, userID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer rows.Close()
|
|
role := ""
|
|
for rows.Next() {
|
|
var p string
|
|
if rows.Scan(&p) != nil {
|
|
continue
|
|
}
|
|
if p == "write" {
|
|
return DocRoleEditor
|
|
}
|
|
role = DocRoleViewer
|
|
}
|
|
return role
|
|
}
|
|
|
|
// CanEditDoc reports whether the role allows changing a note's content.
|
|
func CanEditDoc(role string) bool {
|
|
return role == DocRoleOwner || role == DocRoleEditor
|
|
}
|
|
|
|
// IsDocShared reports whether anyone besides the owner can access the note.
|
|
func (s *Store) IsDocShared(docID string) bool {
|
|
var n int
|
|
_ = s.db.QueryRow(
|
|
`SELECT (SELECT COUNT(*) FROM doc_shares WHERE doc_id = ?)
|
|
+ (SELECT COUNT(*) FROM doc_team_shares WHERE doc_id = ?)
|
|
+ (SELECT COUNT(*) FROM documents d JOIN workspace_folder_shares wfs ON wfs.folder_id = d.shared_folder_id
|
|
WHERE d.id = ? AND d.shared_folder_id != '')`,
|
|
docID, docID, docID).Scan(&n)
|
|
return n > 0
|
|
}
|
|
|
|
// --- Adapter for the existing whole-document API (PUT /documents/{id}) ---
|
|
|
|
const legacyClientID = "legacy"
|
|
|
|
// replaceDocBlocksInDB stores a complete flat block list by diffing it against the current tree and
|
|
// applying the difference as regular operations, so it shows up in the log like any other change.
|
|
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, _ string) {
|
|
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
|
if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op { return legacyOps(d, blocks) }); err != nil {
|
|
slog.Error("Speichern der Notiz fehlgeschlagen", "doc_id", docID, "error", err)
|
|
}
|
|
}
|
|
|
|
// applyLegacyTitle records a title change from the whole-document API in the log.
|
|
func (s *Store) applyLegacyTitle(docID, userID, title string) {
|
|
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
|
if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op {
|
|
if d.Title == title {
|
|
return nil
|
|
}
|
|
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetTitle, BaseVersion: d.TitleVersion, Title: &title}}
|
|
}); err != nil {
|
|
slog.Error("Speichern des Notiztitels fehlgeschlagen", "doc_id", docID, "error", err)
|
|
}
|
|
}
|
|
|
|
// legacyOps computes the operations that turn the current tree into the given flat list.
|
|
// Last writer wins, exactly like the old whole-document save.
|
|
func legacyOps(d *collab.Doc, blocks []domain.DocumentBlock) []collab.Op {
|
|
// Normalize IDs (missing or duplicated IDs get new ones)
|
|
seen := make(map[string]bool, len(blocks))
|
|
list := make([]domain.DocumentBlock, len(blocks))
|
|
for i, b := range blocks {
|
|
id := strings.TrimSpace(b.ID)
|
|
if id == "" || seen[id] {
|
|
id = domain.NewID("blk")
|
|
}
|
|
seen[id] = true
|
|
b.ID = id
|
|
if b.Type == "" {
|
|
b.Type = "bullet"
|
|
}
|
|
list[i] = b
|
|
}
|
|
|
|
parentIdx, _ := treeFromLevels(len(list), func(i int) int { return list[i].Level })
|
|
parentOf := make([]string, len(list))
|
|
children := map[string][]int{}
|
|
for i := range list {
|
|
if parentIdx[i] >= 0 {
|
|
parentOf[i] = list[parentIdx[i]].ID
|
|
}
|
|
children[parentOf[i]] = append(children[parentOf[i]], i)
|
|
}
|
|
keys := make([]string, len(list))
|
|
for parent, idxs := range children {
|
|
assignKeys(d, parent, idxs, list, keys)
|
|
}
|
|
|
|
var ops []collab.Op
|
|
newOp := func(op collab.Op) {
|
|
op.OpID = domain.NewID("op")
|
|
ops = append(ops, op)
|
|
}
|
|
wanted := make(map[string]bool, len(list))
|
|
for i, b := range list {
|
|
wanted[b.ID] = true
|
|
parent := parentOf[i]
|
|
patch := &collab.PropsPatch{Type: &list[i].Type, Checked: &list[i].Checked, Collapsed: &list[i].Collapsed, Lang: &list[i].Lang}
|
|
cur, exists := d.Blocks[b.ID]
|
|
if !exists {
|
|
content := b.Content
|
|
props := collab.Props{Checked: b.Checked, Collapsed: b.Collapsed, Lang: b.Lang}
|
|
newOp(collab.Op{Kind: collab.OpInsert, BlockID: b.ID, ParentID: &parent, OrderKey: keys[i], Type: b.Type, Content: &content, Props: &props})
|
|
continue
|
|
}
|
|
if cur.Deleted {
|
|
newOp(collab.Op{Kind: collab.OpRestore, IDs: []string{b.ID}})
|
|
}
|
|
if cur.ParentID != parent || cur.OrderKey != keys[i] || cur.Deleted {
|
|
newOp(collab.Op{Kind: collab.OpMove, BlockID: b.ID, ParentID: &parent, OrderKey: keys[i]})
|
|
}
|
|
if cur.Content != b.Content {
|
|
content := b.Content
|
|
newOp(collab.Op{Kind: collab.OpSetContent, BlockID: b.ID, BaseVersion: cur.Version, Content: &content})
|
|
}
|
|
if cur.Type != b.Type || cur.Props.Checked != b.Checked || cur.Props.Collapsed != b.Collapsed || cur.Props.Lang != b.Lang {
|
|
newOp(collab.Op{Kind: collab.OpSetProps, BlockID: b.ID, Patch: patch})
|
|
}
|
|
}
|
|
// Deletes last, after moves have rescued wanted children out of deleted branches
|
|
for _, fb := range d.Flatten() {
|
|
if !wanted[fb.ID] {
|
|
newOp(collab.Op{Kind: collab.OpDelete, BlockID: fb.ID})
|
|
}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
// assignKeys picks order keys for the children idxs of parent. Existing keys that are already in the right
|
|
// relative order (longest increasing subsequence) are kept, so unchanged blocks do not show up as moves.
|
|
func assignKeys(d *collab.Doc, parent string, idxs []int, list []domain.DocumentBlock, keys []string) {
|
|
n := len(idxs)
|
|
existing := make([]string, n)
|
|
for j, i := range idxs {
|
|
if b, ok := d.Blocks[list[i].ID]; ok && !b.Deleted && b.ParentID == parent {
|
|
existing[j] = b.OrderKey
|
|
}
|
|
}
|
|
keep := longestIncreasing(existing)
|
|
for j := 0; j < n; {
|
|
if keep[j] {
|
|
keys[idxs[j]] = existing[j]
|
|
j++
|
|
continue
|
|
}
|
|
start := j
|
|
for j < n && !keep[j] {
|
|
j++
|
|
}
|
|
left, right := "", ""
|
|
if start > 0 {
|
|
left = keys[idxs[start-1]]
|
|
}
|
|
if j < n {
|
|
right = existing[j]
|
|
}
|
|
fresh, err := collab.KeysBetween(left, right, j-start)
|
|
if err != nil {
|
|
fresh, _ = collab.KeysBetween(left, "", j-start)
|
|
}
|
|
for k := start; k < j; k++ {
|
|
keys[idxs[k]] = fresh[k-start]
|
|
}
|
|
}
|
|
}
|
|
|
|
// longestIncreasing marks a longest strictly increasing subsequence of the non-empty keys.
|
|
func longestIncreasing(keys []string) []bool {
|
|
n := len(keys)
|
|
keep := make([]bool, n)
|
|
tails := []int{} // indices of the smallest tail for each length
|
|
prev := make([]int, n) // predecessor in the subsequence
|
|
for i, k := range keys {
|
|
prev[i] = -1
|
|
if k == "" {
|
|
continue
|
|
}
|
|
lo, hi := 0, len(tails)
|
|
for lo < hi {
|
|
mid := (lo + hi) / 2
|
|
if keys[tails[mid]] < k {
|
|
lo = mid + 1
|
|
} else {
|
|
hi = mid
|
|
}
|
|
}
|
|
if lo > 0 {
|
|
prev[i] = tails[lo-1]
|
|
}
|
|
if lo == len(tails) {
|
|
tails = append(tails, i)
|
|
} else {
|
|
tails[lo] = i
|
|
}
|
|
}
|
|
if len(tails) > 0 {
|
|
for i := tails[len(tails)-1]; i >= 0; i = prev[i] {
|
|
keep[i] = true
|
|
}
|
|
}
|
|
return keep
|
|
}
|