Files
MindSift/internal/storage/restructure.go
T
sandrodachandClaude Opus 5.5 362dd3b4c6
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s
fix(workspace): extract replaces the branch in the editor, with undo
The server only creates the new note and returns the link; the editor
swaps the branch for it as an ordinary own edit through the sync engine.
No more clash with unsent typing (conflict copies), the link shows at
once also with polling, and Strg+Z restores the branch. Undoing (toast or
Strg+Z) puts the new note into the trash via POST /extract/undo. The
sidebar gets the new note right away and opens its folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:18:25 +01:00

279 lines
9.0 KiB
Go

package storage
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/domain"
)
// Restructuring notes: turn a bullet with its children into a note of its own, and merge one note into
// another. Both change the affected notes through the normal operation log, so open editors follow
// live, and nothing is lost: an extracted branch lives on in the new note, a merged note goes to the
// trash and the merge can be undone.
var (
ErrBlockNotFound = errors.New("Punkt nicht gefunden")
ErrBranchLocked = errors.New("Jemand anderes bearbeitet gerade einen Punkt in diesem Ast. Bitte kurz warten.")
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
)
// readCollabDoc loads the current state of a note outside of a change.
func (s *Store) readCollabDoc(docID string) (*collab.Doc, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
doc, _, err := loadCollabDoc(tx, docID)
return doc, err
}
var (
titlePrefixRe = regexp.MustCompile(`^(\s*(-|\*|#{1,6}|\[[ xX]?\])\s+)+`)
titleTagRe = regexp.MustCompile(`(^|\s)#[\p{L}0-9_-]+`)
)
// branchTitle makes a note title from a bullet's text (without hashtags) and returns the hashtags,
// which stay next to the link in the original bullet.
func branchTitle(content string) (string, string) {
first := titlePrefixRe.ReplaceAllString(strings.SplitN(content, "\n", 2)[0], "")
var tags []string
for _, m := range titleTagRe.FindAllString(first, -1) {
tags = append(tags, strings.TrimSpace(m))
}
t := strings.TrimSpace(titleTagRe.ReplaceAllString(first, ""))
t = strings.NewReplacer("[[", "", "]]", "").Replace(t)
if r := []rune(t); len(r) > 120 {
t = strings.TrimSpace(string(r[:119])) + "…"
}
if t == "" {
t = "Unbenannte Notiz"
}
return t, strings.Join(tags, " ")
}
// uniqueTitle appends " (2)", " (3)" … when the user already has a note of that name, so the wiki link
// points to exactly one note.
func (s *Store) uniqueTitle(userID, title string) string {
docs, _ := s.GetDocuments(userID)
taken := map[string]bool{}
for _, d := range docs {
taken[strings.ToLower(d.Title)] = true
}
candidate := title
for n := 2; taken[strings.ToLower(candidate)]; n++ {
candidate = fmt.Sprintf("%s (%d)", title, n)
}
return candidate
}
func toDocumentBlock(b collab.FlatBlock, level int, pos int) domain.DocumentBlock {
return domain.DocumentBlock{
ID: domain.NewID("blk"), Type: b.Type, Content: b.Content, Level: level, Position: float64(pos + 1),
Checked: b.Props.Checked, Collapsed: b.Props.Collapsed, Lang: b.Props.Lang,
}
}
func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool {
if leases == nil {
return false
}
for _, id := range ids {
if holder, ok := leases.HolderOf(id); ok && holder != a.ClientID {
return true
}
}
return false
}
// ExtractBranch creates a new note from a bullet's children, named after the bullet, in the same folder
// and shared like the original. The original note is not changed here: the editor replaces the branch
// by the returned link itself, as an ordinary own edit (no clash with unsent typing, undo works).
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, string, error) {
source, err := s.GetDocumentByID(userID, docID)
if err != nil {
return nil, "", err
}
state, err := s.readCollabDoc(docID)
if err != nil {
return nil, "", err
}
flat := state.Flatten()
start := -1
for i, b := range flat {
if b.ID == blockID {
start = i
break
}
}
if start < 0 {
return nil, "", ErrBlockNotFound
}
root := flat[start]
var children []domain.DocumentBlock
ids := []string{root.ID}
for i := start + 1; i < len(flat) && flat[i].Level > root.Level; i++ {
children = append(children, toDocumentBlock(flat[i], flat[i].Level-root.Level-1, len(children)))
ids = append(ids, flat[i].ID)
}
if foreignLeaseIn(ids, a, leases) {
return nil, "", ErrBranchLocked
}
base, tags := branchTitle(root.Content)
title := s.uniqueTitle(userID, base)
created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false)
if err != nil {
return nil, "", err
}
s.copyShares(userID, source, created.ID)
link := "[[" + title + "]]"
if tags != "" {
link += " " + tags
}
return created, link, nil
}
// UndoExtract puts a note created by ExtractBranch into the trash (only its creator may).
func (s *Store) UndoExtract(userID, docID string) error {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return err
}
if !doc.IsOwner {
return ErrMergeNotOwner
}
if doc.IsArchived {
return nil
}
return s.trashDocument(userID, docID)
}
// copyShares gives the new note the direct and team shares of the source; when someone else owns the
// source, that owner may edit the new note as well (the branch was theirs to read before).
func (s *Store) copyShares(userID string, source *domain.Document, newDocID string) {
shares, _ := s.GetDocShares(source.ID)
for _, sh := range shares {
switch {
case sh.TargetType == "team":
_, _ = s.CreateDocTeamShare(userID, newDocID, sh.TargetID, sh.Permission)
case sh.UserID != "" && sh.UserID != userID:
_, _ = s.CreateDocShare(userID, newDocID, sh.UserID, sh.Permission)
}
}
if source.UserID != "" && source.UserID != userID {
_, _ = s.CreateDocShare(userID, newDocID, source.UserID, "write")
}
}
// MergeResult tells the client what to undo.
type MergeResult struct {
TargetID string `json:"targetId"`
SourceID string `json:"sourceId"`
SourceTitle string `json:"sourceTitle"`
HeadingID string `json:"headingId"`
}
// MergeDocuments appends the source note to the end of the target: a heading with the source's title,
// its blocks below it. The source goes to the trash (it must belong to the user; the caller checks
// write access to the target).
func (s *Store) MergeDocuments(userID, sourceID, targetID string, a collab.Actor, leases collab.LeaseView) (*MergeResult, error) {
if sourceID == targetID {
return nil, ErrMergeSelf
}
source, err := s.GetDocumentByID(userID, sourceID)
if err != nil {
return nil, err
}
if !source.IsOwner {
return nil, ErrMergeNotOwner
}
if source.IsArchived {
return nil, ErrDocInTrash
}
state, err := s.readCollabDoc(sourceID)
if err != nil {
return nil, err
}
flat := state.Flatten()
headingID := domain.NewID("blk")
heading := strings.TrimSpace(source.Icon + " " + source.Title)
if heading == "" {
heading = "Zusammengeführte Notiz"
}
heading += " (" + time.Now().Format("02.01.2006") + ")"
_, _, err = s.applyDocChange(targetID, a, leases, func(d *collab.Doc) []collab.Op {
// After the last top-level block, deleted ones included, so keys never collide
last := ""
for _, b := range d.Blocks {
if b.ParentID == "" && b.OrderKey > last {
last = b.OrderKey
}
}
key, err := collab.KeyBetween(last, "")
if err != nil {
return nil
}
root := ""
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: headingID, ParentID: &root, OrderKey: key, Type: "h2", Content: &heading}}
// Parents by level: the heading holds the source's top level
parents := []string{headingID}
lastKey := map[string]string{}
for _, b := range flat {
if b.Level+1 > len(parents) {
continue // cannot happen with a flattened tree; guards against gaps
}
parents = parents[:b.Level+1]
parent := parents[b.Level]
k, err := collab.KeyBetween(lastKey[parent], "")
if err != nil {
return nil
}
lastKey[parent] = k
id := domain.NewID("blk")
content := b.Content
props := collab.Props{Checked: b.Props.Checked, Collapsed: b.Props.Collapsed, Lang: b.Props.Lang}
p := parent
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: id, ParentID: &p, OrderKey: k, Type: b.Type, Content: &content, Props: &props})
parents = append(parents, id)
}
return ops
})
if err != nil {
return nil, err
}
if err := s.trashDocument(userID, sourceID); err != nil {
return nil, err
}
return &MergeResult{TargetID: targetID, SourceID: sourceID, SourceTitle: source.Title, HeadingID: headingID}, nil
}
// UndoMerge removes the appended section from the target and brings the source back from the trash.
func (s *Store) UndoMerge(userID string, m MergeResult, a collab.Actor, leases collab.LeaseView) error {
if err := s.RestoreDocument(userID, m.SourceID); err != nil {
return err
}
_, _, err := s.applyDocChange(m.TargetID, a, leases, func(d *collab.Doc) []collab.Op {
if b, ok := d.Blocks[m.HeadingID]; !ok || b.Deleted {
return nil
}
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: m.HeadingID}}
})
return err
}
// DocHead is the newest log position of a note (to tell open editors to sync).
func (s *Store) DocHead(docID string) int64 {
var head int64
_ = s.db.QueryRow(`SELECT head_seq FROM documents WHERE id = ?`, docID).Scan(&head)
return head
}