MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
- 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>
285 lines
9.3 KiB
Go
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
|
|
}
|