Files
MindSift/internal/storage/restructure.go
T
sandrodachandClaude Opus 5.5 68bc4d279c
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
fix(workspace): hidden bullet actions on phones, no second extract, notice layout
- The floating bullet actions only react to hover on devices with a real
  pointer; a tap on a phone made the invisible buttons clickable.
- A bullet that is only a note link (plus hashtags) can no longer be
  extracted: button and /extract are hidden, the server answers 400.
- Extract notice on phones sits above the bottom navigation, text on
  top (two lines max) with the actions below.
- Release v0.26.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:56:32 +01:00

285 lines
9.3 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")
ErrAlreadyNote = errors.New("Dieser Punkt verweist schon auf eine eigene Notiz")
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_-]+`)
// A bullet that is only a link to a note (with hashtags), as left behind by ExtractBranch
noteLinkOnlyRe = regexp.MustCompile(`^\s*\[\[[^\]]+\]\](\s+#[\p{L}0-9_-]+)*\s*$`)
)
// 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]
if noteLinkOnlyRe.MatchString(root.Content) {
return nil, "", ErrAlreadyNote
}
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
}