fix(workspace): extract replaces the branch in the editor, with undo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s
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>
This commit is contained in:
1 parent
02ce554c02
commit
362dd3b4c6
10 files changed
+163
-71
No files matched your search
@@ -53,17 +53,31 @@ func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "blockId fehlt")
|
||||
return
|
||||
}
|
||||
created, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID))
|
||||
created, link, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID))
|
||||
if err != nil {
|
||||
writeError(w, restructureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
s.notifyDocChanged(docID, s.store.DocHead(docID))
|
||||
if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil {
|
||||
created = full
|
||||
}
|
||||
s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED")
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"document": created, "link": link})
|
||||
}
|
||||
|
||||
// handleUndoExtract: POST /documents/{id}/extract/undo puts the extracted note {id} into the trash.
|
||||
func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
users, _ := s.store.GetAuthorizedDocUsers(docID)
|
||||
if err := s.store.UndoExtract(userID, docID); err != nil {
|
||||
writeError(w, restructureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
for _, uid := range users {
|
||||
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
|
||||
}
|
||||
|
||||
// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note.
|
||||
|
||||
@@ -41,16 +41,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("extract: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" {
|
||||
var out struct {
|
||||
Document domain.Document
|
||||
Link string
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
created := out.Document
|
||||
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" || out.Link != "[[Konzept: Garten]] #draußen" {
|
||||
t.Fatalf("new note: %q in %q", created.Title, created.FolderPath)
|
||||
}
|
||||
if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" {
|
||||
t.Fatalf("new note blocks: %s", got)
|
||||
}
|
||||
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" {
|
||||
t.Fatalf("source after extract: %s", got)
|
||||
// The server leaves the source alone; the editor replaces the branch itself
|
||||
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "- Konzept: Garten #draußen|>Beete|>>Erde kaufen|>Zaun|Danach" {
|
||||
t.Fatalf("source must stay unchanged on the server: %s", got)
|
||||
}
|
||||
// Same title again: the link must stay unique
|
||||
_, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false)
|
||||
@@ -61,10 +66,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
|
||||
p2 = d.ID
|
||||
}
|
||||
}
|
||||
var second domain.Document
|
||||
var second struct{ Document domain.Document }
|
||||
_ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second)
|
||||
if second.Title != "Konzept: Garten (2)" {
|
||||
t.Fatalf("duplicate title: %q", second.Title)
|
||||
if second.Document.Title != "Konzept: Garten (2)" {
|
||||
t.Fatalf("duplicate title: %q", second.Document.Title)
|
||||
}
|
||||
// Undo puts the new note into the trash; only its creator may
|
||||
_, strangerToken := newTestUser(t, store, "fremd2@example.com")
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", strangerToken, "", ""); rec.Code == http.StatusOK {
|
||||
t.Fatal("stranger must not undo")
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", token, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("undo: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if d, _ := store.GetDocumentByID(uid, created.ID); d == nil || !d.IsArchived {
|
||||
t.Fatal("extracted note must be in the trash after undo")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +103,9 @@ func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
|
||||
t.Fatalf("reader must not extract: %d", rec.Code)
|
||||
}
|
||||
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "")
|
||||
var created domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
var out struct{ Document domain.Document }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
created := out.Document
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
|
||||
r.Get("/api/v1/media/{id}", s.handleGetMedia)
|
||||
r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch)
|
||||
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
|
||||
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
|
||||
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
|
||||
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
|
||||
|
||||
@@ -92,16 +92,17 @@ func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractBranch moves a bullet's children into a new note named after the bullet, in the same folder,
|
||||
// shared like the original; the bullet itself becomes a [[link]] to it. Returns the new note.
|
||||
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, error) {
|
||||
// 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
|
||||
return nil, "", err
|
||||
}
|
||||
state, err := s.readCollabDoc(docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
flat := state.Flatten()
|
||||
start := -1
|
||||
@@ -112,7 +113,7 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return nil, ErrBlockNotFound
|
||||
return nil, "", ErrBlockNotFound
|
||||
}
|
||||
root := flat[start]
|
||||
var children []domain.DocumentBlock
|
||||
@@ -122,37 +123,36 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
|
||||
ids = append(ids, flat[i].ID)
|
||||
}
|
||||
if foreignLeaseIn(ids, a, leases) {
|
||||
return nil, ErrBranchLocked
|
||||
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
|
||||
return nil, "", err
|
||||
}
|
||||
s.copyShares(userID, source, created.ID)
|
||||
|
||||
link := "[[" + title + "]]"
|
||||
if tags != "" {
|
||||
link += " " + tags
|
||||
}
|
||||
_, _, err = s.applyDocChange(docID, a, leases, func(d *collab.Doc) []collab.Op {
|
||||
b, ok := d.Blocks[blockID]
|
||||
if !ok || b.Deleted {
|
||||
return nil
|
||||
}
|
||||
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}}
|
||||
for _, c := range d.Children(blockID) {
|
||||
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: c.ID})
|
||||
}
|
||||
return ops
|
||||
})
|
||||
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 {
|
||||
_ = s.hardDeleteDocument(created.ID)
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
return created, nil
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user