Files
MindSift/internal/api/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

138 lines
5.1 KiB
Go

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), errors.Is(err, storage.ErrAlreadyNote):
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, 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
}
if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil {
created = full
}
s.broadcastDocToAuthorizedUsers(created.ID, "DOC_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.
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})
}