refactor(notes): remove legacy whole-document saves; all block changes go through sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m14s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m14s
Phase 5 of the collaboration plan:
- Remove PATCH /documents/{id}/blocks, PatchDocumentBlocks, the
DOC_BLOCKS_PATCHED event and its types, FlushDocumentState and
LoadDocumentState. PUT /documents/{id} rejects blocks (400) and only
changes title, icon, folder and pin; editors may now change title/icon.
- The to-do overview ticks via a single operation on /docs/{id}/sync
(new skipLog flag). Task links set the block tick with a targeted
operation and notify open editors; creating a linked task needs edit
rights.
- Keep the three share tables: DocRole already decides centrally, and
rewriting the visibility queries would only add risk (documented in plan).
- Tests adapted to the new paths; share e2e test now also ticks a to-do in
the overview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b7a53daab
commit
aea4324c20
18 files changed
+155
-376
No files matched your search
@@ -86,6 +86,7 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) {
|
||||
Hold []string `json:"hold"`
|
||||
Release []string `json:"release"`
|
||||
} `json:"leases"`
|
||||
SkipLog bool `json:"skipLog"` // one-off changes (e.g. ticking a to-do in the overview) need no log
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, syncMaxBody)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -149,12 +150,16 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) {
|
||||
s.afterDocChange(docID, req.Ops, resp.Results)
|
||||
}
|
||||
|
||||
entries, head, err := s.store.GetDocOpsSince(docID, req.LastSeq, syncMaxLog)
|
||||
logLimit := syncMaxLog
|
||||
if req.SkipLog {
|
||||
logLimit = 0
|
||||
}
|
||||
entries, head, err := s.store.GetDocOpsSince(docID, req.LastSeq, logLimit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if req.LastSeq > head {
|
||||
if req.LastSeq > head && !req.SkipLog {
|
||||
resp.Resync = true
|
||||
entries = []storage.DocLogEntry{}
|
||||
}
|
||||
|
||||
+16
-39
@@ -159,7 +159,6 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||||
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
||||
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
|
||||
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
||||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||||
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
|
||||
@@ -903,7 +902,10 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
|
||||
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
|
||||
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
|
||||
s.notifyDocChanged(docID, head)
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
@@ -1019,59 +1021,32 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
Icon *string `json:"icon"`
|
||||
FolderPath *string `json:"folderPath"`
|
||||
Blocks *[]domain.DocumentBlock `json:"blocks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
IsPinned *bool `json:"isPinned"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.Blocks != nil {
|
||||
writeError(w, http.StatusBadRequest, "Blöcke werden über POST /api/v1/docs/{id}/sync gespeichert")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||||
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.IsPinned)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||
if req.Title != nil {
|
||||
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
|
||||
s.notifyDocChanged(docID, head)
|
||||
}
|
||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil {
|
||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
var req domain.DocBlocksPatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
doc, mergedOrder, err := s.store.PatchDocumentBlocks(userID, docID, req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
patchEvent := domain.DocBlocksPatchEvent{
|
||||
DocID: docID,
|
||||
UpdatedBy: userID,
|
||||
UpdatedAt: doc.UpdatedAt,
|
||||
Upserted: req.Upserted,
|
||||
DeletedIDs: req.DeletedIDs,
|
||||
BlockOrder: mergedOrder,
|
||||
}
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_BLOCKS_PATCHED", patchEvent)
|
||||
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
||||
for _, uid := range users {
|
||||
s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
@@ -1106,6 +1081,8 @@ func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
|
||||
s.notifyDocChanged(docID, head)
|
||||
|
||||
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
|
||||
writeJSON(w, http.StatusCreated, thought)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
@@ -1269,17 +1270,30 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||
t.Fatalf("expected error for User B updating document, got 200 OK")
|
||||
}
|
||||
|
||||
// 3. User B tries to patch document blocks -> must fail (not owner)
|
||||
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
|
||||
// 3. User B tries to change blocks through the sync API -> must fail (read-only)
|
||||
hacked := "Hacked by User B"
|
||||
syncBody, _ := json.Marshal(map[string]interface{}{
|
||||
"clientId": "tab-b",
|
||||
"ops": []collab.Op{{OpID: "b-hack", Kind: collab.OpSetContent, BlockID: "b2", BaseVersion: 1, Content: &hacked}},
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/docs/"+doc.ID+"/sync", bytes.NewReader(syncBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected error for User B patching document blocks, got 200 OK")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for User B changing blocks, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// The old whole-document save no longer accepts blocks, not even from the owner
|
||||
blocksBody, _ := json.Marshal(map[string]interface{}{"blocks": []domain.DocumentBlock{{ID: "b1", Content: "x"}}})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(blocksBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for blocks via PUT, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 4. User A (owner) can successfully update document
|
||||
|
||||
@@ -309,25 +309,6 @@ func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type DocBlocksPatchRequest struct {
|
||||
Upserted []DocumentBlock `json:"upserted,omitempty"`
|
||||
DeletedIDs []string `json:"deletedIds,omitempty"`
|
||||
BlockOrder []string `json:"blockOrder,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type DocBlocksPatchEvent struct {
|
||||
DocID string `json:"docId"`
|
||||
Upserted []DocumentBlock `json:"upserted,omitempty"`
|
||||
DeletedIDs []string `json:"deletedIds,omitempty"`
|
||||
BlockOrder []string `json:"blockOrder,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DocShare struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"docId"`
|
||||
|
||||
@@ -565,12 +565,26 @@ func (s *Store) IsDocShared(docID string) bool {
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// --- Adapter for the existing whole-document API (PUT /documents/{id}) ---
|
||||
// SetBlockChecked ticks or unticks a block through the log (server-side change, e.g. a linked task was
|
||||
// completed). Returns true if the block changed.
|
||||
func (s *Store) SetBlockChecked(userID, docID, blockID string, checked bool) bool {
|
||||
a := collab.Actor{UserID: userID, ClientID: "server"}
|
||||
results, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op {
|
||||
b, ok := d.Blocks[blockID]
|
||||
if !ok || b.Deleted || b.Props.Checked == checked {
|
||||
return nil
|
||||
}
|
||||
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetProps, BlockID: blockID, Patch: &collab.PropsPatch{Checked: &checked}}}
|
||||
})
|
||||
return err == nil && len(results) > 0 && results[0].Seq > 0
|
||||
}
|
||||
|
||||
// --- Writing a complete block list (new notes, imports) ---
|
||||
|
||||
const legacyClientID = "legacy"
|
||||
|
||||
// replaceDocBlocksInDB stores a complete flat block list by diffing it against the current tree and
|
||||
// applying the difference as regular operations, so it shows up in the log like any other change.
|
||||
// replaceDocBlocksInDB stores a complete flat block list (initial blocks of a new note, old blocks_json data)
|
||||
// by diffing it against the current tree and applying the difference as regular operations.
|
||||
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, _ string) {
|
||||
blocks = s.renameForeignBlockIDs(docID, blocks)
|
||||
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
||||
@@ -602,7 +616,7 @@ func (s *Store) renameForeignBlockIDs(docID string, blocks []domain.DocumentBloc
|
||||
return out
|
||||
}
|
||||
|
||||
// applyLegacyTitle records a title change from the whole-document API in the log.
|
||||
// applyLegacyTitle records a title change made through PUT /documents/{id} in the log.
|
||||
func (s *Store) applyLegacyTitle(docID, userID, title string) {
|
||||
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
||||
if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op {
|
||||
|
||||
@@ -77,9 +77,7 @@ func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) {
|
||||
|
||||
// Saving the same content again must not produce any log entries
|
||||
head := headSeq(t, s, doc.ID)
|
||||
if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &initial, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.replaceDocBlocksInDB(doc.ID, u.ID, initial, "")
|
||||
if h := headSeq(t, s, doc.ID); h != head {
|
||||
t.Fatalf("unchanged save wrote %d log entries", h-head)
|
||||
}
|
||||
@@ -92,9 +90,7 @@ func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) {
|
||||
[3]interface{}{"c", 1, "Detail"},
|
||||
[3]interface{}{"n", 2, "Neu"},
|
||||
)
|
||||
if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &changed, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.replaceDocBlocksInDB(doc.ID, u.ID, changed, "")
|
||||
got, _ = s.GetDocumentByID(u.ID, doc.ID)
|
||||
sameOutline(t, got.Blocks, changed)
|
||||
|
||||
|
||||
+23
-222
@@ -1471,97 +1471,6 @@ func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.Docume
|
||||
return blocks
|
||||
}
|
||||
|
||||
func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) {
|
||||
var title, blocksJSON string
|
||||
err := s.db.QueryRow(`SELECT title, blocks_json FROM documents WHERE id = ?`, docID).Scan(&title, &blocksJSON)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
blocks := s.loadDocBlocks(docID, blocksJSON)
|
||||
return title, blocks, nil
|
||||
}
|
||||
|
||||
// FlushDocumentState stores a complete block list (and optionally the title) through the operation log.
|
||||
func (s *Store) FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error {
|
||||
s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO())
|
||||
if strings.TrimSpace(title) != "" {
|
||||
s.applyLegacyTitle(docID, "", title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchDocumentBlocks applies block upserts, deletions and an optional order to a note through the
|
||||
// operation log. Blocks not mentioned keep their position relative to each other.
|
||||
func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPatchRequest) (*domain.Document, []string, error) {
|
||||
if !CanEditDoc(s.DocRole(userID, docID)) {
|
||||
return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
deleted := make(map[string]bool, len(req.DeletedIDs))
|
||||
for _, id := range req.DeletedIDs {
|
||||
deleted[strings.TrimSpace(id)] = true
|
||||
}
|
||||
upserts := make(map[string]domain.DocumentBlock, len(req.Upserted))
|
||||
for _, b := range req.Upserted {
|
||||
if id := strings.TrimSpace(b.ID); id != "" && !deleted[id] {
|
||||
upserts[id] = b
|
||||
}
|
||||
}
|
||||
|
||||
merged := make([]domain.DocumentBlock, 0, len(doc.Blocks)+len(upserts))
|
||||
used := make(map[string]bool, len(upserts))
|
||||
for _, b := range doc.Blocks {
|
||||
if deleted[b.ID] {
|
||||
continue
|
||||
}
|
||||
if up, ok := upserts[b.ID]; ok {
|
||||
merged = append(merged, up)
|
||||
used[b.ID] = true
|
||||
continue
|
||||
}
|
||||
merged = append(merged, b)
|
||||
}
|
||||
for _, b := range req.Upserted {
|
||||
if id := strings.TrimSpace(b.ID); id != "" && !used[id] && !deleted[id] {
|
||||
merged = append(merged, b)
|
||||
used[id] = true
|
||||
}
|
||||
}
|
||||
if len(req.BlockOrder) > 0 {
|
||||
rank := make(map[string]int, len(req.BlockOrder))
|
||||
for i, id := range req.BlockOrder {
|
||||
rank[id] = i
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
ri, okI := rank[merged[i].ID]
|
||||
rj, okJ := rank[merged[j].ID]
|
||||
if okI && okJ {
|
||||
return ri < rj
|
||||
}
|
||||
return okI && !okJ
|
||||
})
|
||||
}
|
||||
|
||||
s.replaceDocBlocksInDB(docID, userID, merged, domain.NowISO())
|
||||
if req.Title != nil {
|
||||
s.applyLegacyTitle(docID, userID, strings.TrimSpace(*req.Title))
|
||||
}
|
||||
|
||||
updated, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
order := make([]string, len(updated.Blocks))
|
||||
for i, b := range updated.Blocks {
|
||||
order[i] = b.ID
|
||||
}
|
||||
return updated, order, nil
|
||||
}
|
||||
|
||||
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
|
||||
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
|
||||
userSet := make(map[string]bool)
|
||||
@@ -2137,16 +2046,16 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) {
|
||||
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, isPinned *bool) (*domain.Document, error) {
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
|
||||
isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil
|
||||
|
||||
if !doc.IsOwner && !isOnlyPinChange && !isOnlyFolderMove {
|
||||
// Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is
|
||||
// only changed through the operation log (POST /docs/{id}/sync).
|
||||
changesContent := title != nil || icon != nil
|
||||
if changesContent && !CanEditDoc(s.DocRole(userID, docID)) {
|
||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
|
||||
@@ -2165,10 +2074,6 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
}
|
||||
}
|
||||
|
||||
if isOnlyPinChange {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
if folderPath != nil {
|
||||
movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath)
|
||||
if moveErr != nil {
|
||||
@@ -2180,10 +2085,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
doc.IsSharedFolder = movedDoc.IsSharedFolder
|
||||
}
|
||||
|
||||
if isOnlyFolderMove {
|
||||
if !changesContent {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
if title != nil {
|
||||
doc.Title = strings.TrimSpace(*title)
|
||||
s.applyLegacyTitle(docID, userID, doc.Title)
|
||||
@@ -2191,68 +2095,11 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
if icon != nil {
|
||||
doc.Icon = *icon
|
||||
}
|
||||
if tags != nil {
|
||||
doc.Tags = stripFolderTags(*tags)
|
||||
}
|
||||
|
||||
doc.UpdatedAt = domain.NowISO()
|
||||
if blocks != nil {
|
||||
doc.Blocks = *blocks
|
||||
s.replaceDocBlocksInDB(docID, userID, doc.Blocks, doc.UpdatedAt)
|
||||
var plainBuilder strings.Builder
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Content != "" {
|
||||
plainBuilder.WriteString(b.Content)
|
||||
plainBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
doc.Content = plainBuilder.String()
|
||||
}
|
||||
|
||||
blocksJSON, _ := json.Marshal(doc.Blocks)
|
||||
tagsJSON, _ := json.Marshal(doc.Tags)
|
||||
|
||||
// IMPORTANT: Never overwrite documents.folder_path here! Folder placement is managed exclusively by MoveDocument.
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.UpdatedAt, docID,
|
||||
)
|
||||
if err != nil {
|
||||
if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocks != nil {
|
||||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||||
for _, b := range doc.Blocks {
|
||||
if b.TaskID != "" {
|
||||
s.SyncTaskFromBlockCheck(doc.UserID, b.TaskID, b.Checked)
|
||||
}
|
||||
}
|
||||
|
||||
// When a block is deleted from the note, unlink the corresponding task in thoughts without deleting the task
|
||||
remainingBlockIDs := make(map[string]bool)
|
||||
for _, b := range doc.Blocks {
|
||||
remainingBlockIDs[b.ID] = true
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT id, converted_ref_id FROM thoughts WHERE converted_ref_id LIKE ? AND converted_type = 'document_block'`, docID+":%")
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var tid, refID string
|
||||
if err := rows.Scan(&tid, &refID); err == nil {
|
||||
bid := ""
|
||||
if strings.Contains(refID, ":") {
|
||||
parts := strings.SplitN(refID, ":", 2)
|
||||
bid = parts[1]
|
||||
}
|
||||
if !remainingBlockIDs[bid] {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, tid)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
return doc, nil
|
||||
return s.GetDocumentByID(userID, docID)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDocument(userID, docID string) error {
|
||||
@@ -4025,9 +3872,9 @@ func (s *Store) UpdateListItem(itemID string, content *string, category *string,
|
||||
}
|
||||
|
||||
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Linking changes the block (its tick follows the task), so edit rights are needed
|
||||
if !CanEditDoc(s.DocRole(userID, docID)) {
|
||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
|
||||
if err != nil {
|
||||
@@ -4047,76 +3894,30 @@ func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, c
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
|
||||
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
|
||||
|
||||
var updatedBlocks []domain.DocumentBlock
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.ID == blockID {
|
||||
b.TaskID = t.ID
|
||||
b.Checked = checked
|
||||
found = true
|
||||
}
|
||||
updatedBlocks = append(updatedBlocks, b)
|
||||
}
|
||||
if found {
|
||||
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
}
|
||||
s.SetBlockChecked(userID, docID, blockID, checked)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) {
|
||||
// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened).
|
||||
// Returns the note ID if the block changed, so the caller can notify its viewers.
|
||||
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string {
|
||||
t, err := s.GetThoughtByID(userID, taskID)
|
||||
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||
docID, blockID := parts[0], parts[1]
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil || doc == nil {
|
||||
return
|
||||
}
|
||||
var updatedBlocks []domain.DocumentBlock
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.ID == blockID {
|
||||
if b.Checked != isCompleted {
|
||||
b.Checked = isCompleted
|
||||
found = true
|
||||
}
|
||||
}
|
||||
updatedBlocks = append(updatedBlocks, b)
|
||||
}
|
||||
if found {
|
||||
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":")
|
||||
if s.SetBlockChecked(userID, docID, blockID, isCompleted) {
|
||||
return docID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's
|
||||
// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change.
|
||||
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
|
||||
t, err := s.GetThoughtByID(userID, taskID)
|
||||
if err != nil || t == nil {
|
||||
if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil {
|
||||
return
|
||||
}
|
||||
docID := t.DocID
|
||||
blockID := t.BlockID
|
||||
if (docID == "" || blockID == "") && strings.Contains(t.ConvertedRefID, ":") {
|
||||
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||
docID, blockID = parts[0], parts[1]
|
||||
}
|
||||
if docID != "" {
|
||||
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
|
||||
var updatedBlocks []domain.DocumentBlock
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.ID == blockID || b.TaskID == taskID {
|
||||
b.TaskID = ""
|
||||
found = true
|
||||
}
|
||||
updatedBlocks = append(updatedBlocks, b)
|
||||
}
|
||||
if found {
|
||||
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
@@ -208,13 +209,13 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
|
||||
// user2 tries to edit read-only doc -> must fail
|
||||
updatedTitle := "Hacked Title"
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||
}
|
||||
|
||||
// user2 is a read-only collaborator -> editing must fail
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
|
||||
}
|
||||
@@ -271,7 +272,7 @@ func TestPerUserDocPinning(t *testing.T) {
|
||||
|
||||
// user2 pins the shared document (even with read-only access)
|
||||
pinTrue := true
|
||||
updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, nil, nil, &pinTrue)
|
||||
updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, &pinTrue)
|
||||
if err != nil {
|
||||
t.Fatalf("user2 pinning read-only doc failed: %v", err)
|
||||
}
|
||||
@@ -290,7 +291,7 @@ func TestPerUserDocPinning(t *testing.T) {
|
||||
|
||||
// user1 unpins the doc
|
||||
pinFalse := false
|
||||
_, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, nil, nil, &pinFalse)
|
||||
_, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, &pinFalse)
|
||||
if err != nil {
|
||||
t.Fatalf("user1 unpinning doc failed: %v", err)
|
||||
}
|
||||
@@ -518,10 +519,7 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
updatedBlocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0},
|
||||
}
|
||||
_, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDocument failed: %v", err)
|
||||
}
|
||||
store.replaceDocBlocksInDB(doc.ID, user.ID, updatedBlocks, "")
|
||||
|
||||
tagsAfter, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
@@ -585,43 +583,22 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
t.Fatalf("MoveDocument by userB failed: %v", err)
|
||||
}
|
||||
|
||||
// User B is a read-only collaborator -> PatchDocumentBlocks must fail
|
||||
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{
|
||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
|
||||
{ID: "b3", Type: "bullet", Content: "Zeile 3 neu von B", Level: 0},
|
||||
},
|
||||
BlockOrder: []string{"b1", "b2", "b3"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded")
|
||||
}
|
||||
|
||||
// User A's client only knew about b1, b2 when sending its patch (adds b4)
|
||||
patchedDocA, _, err := store.PatchDocumentBlocks(userA.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Zeile 1 von A bearbeitet", Level: 0},
|
||||
{ID: "b4", Type: "bullet", Content: "Zeile 4 neu von A", Level: 0},
|
||||
},
|
||||
BlockOrder: []string{"b1", "b2", "b4"}, // stale order without b3!
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
|
||||
if len(patchedDocA.Blocks) != 3 {
|
||||
t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
||||
// User A edits b1 and adds b4 through the operation log; blocks A did not touch stay
|
||||
editedB1, newB4, top := "Zeile 1 von A bearbeitet", "Zeile 4 neu von A", ""
|
||||
res, _, err := store.ApplyDocOps(doc.ID, collab.Actor{UserID: userA.ID, ClientID: "tab-a"}, []collab.Op{
|
||||
{OpID: "a-b1", Kind: collab.OpSetContent, BlockID: "b1", BaseVersion: 1, Content: &editedB1},
|
||||
{OpID: "a-b4", Kind: collab.OpInsert, BlockID: "b4", ParentID: &top, OrderKey: "a9", Content: &newB4},
|
||||
}, nil)
|
||||
if err != nil || res[0].Status != collab.StatusApplied || res[1].Status != collab.StatusApplied {
|
||||
t.Fatalf("ApplyDocOps by userA failed: %+v %v", res, err)
|
||||
}
|
||||
patchedDocA, _ := store.GetDocumentByID(userA.ID, doc.ID)
|
||||
blockContent := make(map[string]string)
|
||||
for _, b := range patchedDocA.Blocks {
|
||||
blockContent[b.ID] = b.Content
|
||||
}
|
||||
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
||||
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
|
||||
}
|
||||
if blockContent["b4"] != "Zeile 4 neu von A" {
|
||||
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
|
||||
if blockContent["b1"] != editedB1 || blockContent["b4"] != newB4 || blockContent["b2"] == "" {
|
||||
t.Fatalf("unexpected blocks after edit: %+v", blockContent)
|
||||
}
|
||||
|
||||
// Verify folder independence: User A still sees "Privat", User B still sees "Arbeit/Eingang"
|
||||
@@ -710,7 +687,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushDocumentStateAndSchema(t *testing.T) {
|
||||
func TestBlockListAndDerivedData(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
|
||||
|
||||
@@ -746,9 +723,8 @@ func TestFlushDocumentStateAndSchema(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
|
||||
t.Fatalf("FlushDocumentState failed: %v", err)
|
||||
}
|
||||
store.replaceDocBlocksInDB(doc.ID, user.ID, blocks, "")
|
||||
store.applyLegacyTitle(doc.ID, user.ID, "Architektur Notiz")
|
||||
|
||||
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user