feat(workspace): turn a branch into its own note, merge notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet becomes the title (hashtags stay at the link), its children the content of a new note in the same folder with the source's shares (a foreign source owner gets write access); the branch becomes [[title]] through the operation log, so open editors follow. - Merge: drop a note onto another in the sidebar or use "Mit anderer Notiz zusammenführen…" in the note header. The own source is appended as an h2 section with its bullets below and goes to the trash; the toast offers a complete undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c26eb2960
commit
4d1b37c956
15 files changed
+934
-9
No files matched your search
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Turning a branch into its own note and merging notes (storage/restructure.go). The editor sends its
|
||||
// client ID, so its own block leases do not block the change.
|
||||
|
||||
func (s *Server) restructureActor(r *http.Request, clientID string) collab.Actor {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
a := collab.Actor{UserID: userID, ClientID: clientID}
|
||||
if u, err := s.store.GetUserByID(userID); err == nil {
|
||||
a.Name = u.DisplayName
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func restructureStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrBranchLocked):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, storage.ErrMergeNotOwner):
|
||||
return http.StatusForbidden
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// handleExtractBranch: POST /documents/{id}/extract {blockId, clientId}
|
||||
func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
if !storage.CanEditDoc(s.store.DocRole(userID, docID)) {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
BlockID string `json:"blockId"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.BlockID == "" {
|
||||
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))
|
||||
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)
|
||||
}
|
||||
|
||||
// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note.
|
||||
func (s *Server) handleMergeDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
targetID := chi.URLParam(r, "id")
|
||||
if !storage.CanEditDoc(s.store.DocRole(userID, targetID)) {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibrechte für die Zielnotiz")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SourceID string `json:"sourceId"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SourceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "sourceId fehlt")
|
||||
return
|
||||
}
|
||||
sourceUsers, _ := s.store.GetAuthorizedDocUsers(req.SourceID)
|
||||
res, err := s.store.MergeDocuments(userID, req.SourceID, targetID, s.restructureActor(r, req.ClientID), s.leases.View(targetID))
|
||||
if err != nil {
|
||||
writeError(w, restructureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
s.notifyDocChanged(targetID, s.store.DocHead(targetID))
|
||||
for _, uid := range sourceUsers {
|
||||
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": req.SourceID})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleUndoMerge: POST /documents/{id}/merge/undo {sourceId, headingId, clientId}
|
||||
func (s *Server) handleUndoMerge(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
targetID := chi.URLParam(r, "id")
|
||||
if !storage.CanEditDoc(s.store.DocRole(userID, targetID)) {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibrechte für die Zielnotiz")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SourceID string `json:"sourceId"`
|
||||
HeadingID string `json:"headingId"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SourceID == "" || req.HeadingID == "" {
|
||||
writeError(w, http.StatusBadRequest, "sourceId und headingId fehlen")
|
||||
return
|
||||
}
|
||||
m := storage.MergeResult{TargetID: targetID, SourceID: req.SourceID, HeadingID: req.HeadingID}
|
||||
if err := s.store.UndoMerge(userID, m, s.restructureActor(r, req.ClientID), s.leases.View(targetID)); err != nil {
|
||||
writeError(w, restructureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
s.notifyDocChanged(targetID, s.store.DocHead(targetID))
|
||||
s.broadcastDocToAuthorizedUsers(req.SourceID, "DOC_CREATED")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
func flatOf(t *testing.T, store *storage.Store, userID, docID string) []string {
|
||||
t.Helper()
|
||||
d, err := store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make([]string, 0, len(d.Blocks))
|
||||
for _, b := range d.Blocks {
|
||||
out = append(out, strings.Repeat(">", b.Level)+b.Content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestExtractBranchIntoOwnNote(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
uid := claims.UserID
|
||||
src, _ := store.CreateDocument(uid, "", "Projekt", "", "Arbeit", []domain.DocumentBlock{
|
||||
{ID: "blk_a", Type: "bullet", Content: "- Konzept: Garten #draußen", Level: 0, Position: 1},
|
||||
{ID: "blk_b", Type: "bullet", Content: "Beete", Level: 1, Position: 2},
|
||||
{ID: "blk_c", Type: "todo", Content: "Erde kaufen", Level: 2, Position: 3, Checked: true},
|
||||
{ID: "blk_d", Type: "bullet", Content: "Zaun", Level: 1, Position: 4},
|
||||
{ID: "blk_e", Type: "bullet", Content: "Danach", Level: 0, Position: 5},
|
||||
}, nil, false)
|
||||
|
||||
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_a"}`, "")
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
docs, _ := store.GetDocuments(uid)
|
||||
var p2 string
|
||||
for _, d := range docs {
|
||||
if d.Title == "Projekt 2" {
|
||||
p2 = d.ID
|
||||
}
|
||||
}
|
||||
var second 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
owner, _ := newTestUser(t, store, "owner@example.com")
|
||||
editor, editorToken := newTestUser(t, store, "editor@example.com")
|
||||
reader, readerToken := newTestUser(t, store, "reader@example.com")
|
||||
src, _ := store.CreateDocument(owner.ID, "", "Gemeinsam", "", "", []domain.DocumentBlock{
|
||||
{ID: "blk_s1", Type: "bullet", Content: "Idee", Level: 0, Position: 1},
|
||||
{ID: "blk_s2", Type: "bullet", Content: "Detail", Level: 1, Position: 2},
|
||||
}, nil, false)
|
||||
if _, err := store.CreateDocShare(owner.ID, src.ID, editor.ID, "write"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = store.CreateDocShare(owner.ID, src.ID, reader.ID, "read")
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", readerToken, `{"blockId":"blk_s1"}`, ""); rec.Code != http.StatusForbidden {
|
||||
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)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if r := store.DocRole(editor.ID, created.ID); r != storage.DocRoleOwner {
|
||||
t.Fatalf("editor should own the new note, got %q", r)
|
||||
}
|
||||
if r := store.DocRole(owner.ID, created.ID); !storage.CanEditDoc(r) {
|
||||
t.Fatalf("source owner must be able to edit the new note, got %q", r)
|
||||
}
|
||||
if r := store.DocRole(reader.ID, created.ID); r == "" || storage.CanEditDoc(r) {
|
||||
t.Fatalf("reader must read (not edit) the new note, got %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDocumentsAndUndo(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
uid := claims.UserID
|
||||
source, _ := store.CreateDocument(uid, "", "Einkauf", "🛒", "", []domain.DocumentBlock{
|
||||
{ID: "blk_m1", Type: "bullet", Content: "Milch", Level: 0, Position: 1},
|
||||
{ID: "blk_m2", Type: "bullet", Content: "laktosefrei", Level: 1, Position: 2},
|
||||
}, nil, false)
|
||||
target, _ := store.CreateDocument(uid, "", "Haushalt", "", "", []domain.DocumentBlock{{ID: "blk_t1", Type: "bullet", Content: "Putzen", Level: 0}}, nil, false)
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("merge into itself: want 400, got %d", rec.Code)
|
||||
}
|
||||
rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+source.ID+`"}`, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("merge: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res storage.MergeResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
got := flatOf(t, store, uid, target.ID)
|
||||
if len(got) != 4 || got[0] != "Putzen" || !strings.HasPrefix(got[1], "🛒 Einkauf (") || got[2] != ">Milch" || got[3] != ">>laktosefrei" {
|
||||
t.Fatalf("target after merge: %v", got)
|
||||
}
|
||||
if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || !d.IsArchived {
|
||||
t.Fatal("source must be in the trash")
|
||||
}
|
||||
|
||||
undo := `{"sourceId":"` + source.ID + `","headingId":"` + res.HeadingID + `"}`
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge/undo", token, undo, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("undo: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := strings.Join(flatOf(t, store, uid, target.ID), "|"); got != "Putzen" {
|
||||
t.Fatalf("target after undo: %s", got)
|
||||
}
|
||||
if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || d.IsArchived {
|
||||
t.Fatal("source must be back")
|
||||
}
|
||||
|
||||
// Someone else's note cannot be the source
|
||||
other, otherToken := newTestUser(t, store, "andere@example.com")
|
||||
own, _ := store.CreateDocument(other.ID, "", "Eigen", "", "", nil, nil, false)
|
||||
if _, err := store.CreateDocShare(uid, target.ID, other.ID, "write"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+own.ID+"/merge", otherToken, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,9 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia)
|
||||
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}/merge", s.handleMergeDocuments)
|
||||
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
|
||||
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
|
||||
r.Get("/api/v1/link-preview/{key}/{kind}", s.handleLinkPreviewPicture)
|
||||
r.Get("/api/v1/auth/sessions", s.handleListSessions)
|
||||
|
||||
Reference in new issue
Block a user