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

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:
sandrodachandClaude Opus 5.5 committed 2026-10-28 14:35:59 +01:00
1 parent 8b7a53daab
commit aea4324c20
18 files changed
+155 -376

No files matched your search

+23 -222
View File
@@ -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)
}