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>
138 lines
5.1 KiB
Go
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})
|
|
}
|