feat(workspace): bring an extracted note back into the bullet

A bullet that is only a link to a note (as left by /extract) gets a
"Notiz zurückholen" button. POST /documents/{id}/inline returns the
linked note's blocks with fresh ids and trashes the note; the editor
puts the title back as text and inserts the blocks as an own edit, so
Strg+Z restores the link and the note. Only own notes that no other
note links to. Release 0.52.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 20:13:53 +01:00
1 parent 75aea4a7d4
commit 39a04082fc
15 files changed
+293 -11

No files matched your search

+97
View File
@@ -22,6 +22,9 @@ var (
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")
ErrNotNoteLink = errors.New("Dieser Punkt ist kein Link auf eine Notiz")
ErrLinkedMissing = errors.New("Die verlinkte Notiz gibt es nicht (mehr)")
ErrInlineOwner = errors.New("Zurückholen geht nur mit eigenen Notizen")
)
// readCollabDoc loads the current state of a note outside of a change.
@@ -161,6 +164,100 @@ func (s *Store) UndoExtract(userID, docID string) error {
return s.trashDocument(userID, docID)
}
// StillLinkedError: the note to bring back is linked from other notes as well
type StillLinkedError struct {
Title string
Count int
}
func (e *StillLinkedError) Error() string {
return fmt.Sprintf("„%s“ ist noch in %d weiteren Notiz(en) verlinkt; diese Links würden ins Leere zeigen. Bitte dort zuerst entfernen.", e.Title, e.Count)
}
// InlineResult: what the editor puts back in place of the link bullet
type InlineResult struct {
SourceID string `json:"sourceId"`
// Text of the bullet: the note's title, hashtags of the link kept
Content string `json:"content"`
// The note's blocks with fresh ids; level 0 = directly below the bullet
Blocks []domain.DocumentBlock `json:"blocks"`
}
// InlineNote is the way back from ExtractBranch: for a bullet that is only a link to an own note, it
// returns that note's blocks and puts the note into the trash. Like ExtractBranch it does not change
// the note with the bullet; the editor inserts the blocks itself as an own edit (undo works). Refused
// while other notes link to the note as well, since those links would point to nothing.
func (s *Store) InlineNote(userID, docID, blockID string) (*InlineResult, error) {
state, err := s.readCollabDoc(docID)
if err != nil {
return nil, err
}
var content string
found := false
for _, b := range state.Flatten() {
if b.ID == blockID {
content, found = b.Content, true
break
}
}
if !found {
return nil, ErrBlockNotFound
}
m := wikiLinkRe.FindStringSubmatch(content)
if !noteLinkOnlyRe.MatchString(content) || m == nil {
return nil, ErrNotNoteLink
}
title := strings.TrimSpace(m[1])
docs, err := s.GetDocuments(userID)
if err != nil {
return nil, err
}
var linked *domain.Document
for i := range docs {
d := &docs[i]
if d.ID == docID || !strings.EqualFold(strings.TrimSpace(d.Title), title) {
continue
}
// An own note wins over one shared with the user under the same title
if linked == nil || (d.IsOwner && !linked.IsOwner) {
linked = d
}
}
if linked == nil {
return nil, ErrLinkedMissing
}
if !linked.IsOwner {
return nil, ErrInlineOwner
}
backlinks, err := s.GetBacklinks(userID, linked.ID)
if err != nil {
return nil, err
}
others := 0
for _, bl := range backlinks {
if bl.DocID != docID {
others++
}
}
if others > 0 {
return nil, &StillLinkedError{Title: linked.Title, Count: others}
}
source, err := s.readCollabDoc(linked.ID)
if err != nil {
return nil, err
}
blocks := []domain.DocumentBlock{}
for _, b := range source.Flatten() {
blocks = append(blocks, toDocumentBlock(b, b.Level, len(blocks)))
}
rest := strings.TrimSpace(wikiLinkRe.ReplaceAllString(content, ""))
text := strings.TrimSpace(linked.Title + " " + rest)
if err := s.trashDocument(userID, linked.ID); err != nil {
return nil, err
}
return &InlineResult{SourceID: linked.ID, Content: text, Blocks: blocks}, nil
}
// 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) {