feat(tasks): linked tasks sync title and subtasks with their note in both directions
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 2m32s

Subtasks of a task linked to a note checkbox are tied 1:1 to checkboxes of that note
(subtasks[].blockId), wherever they are moved. ReconcileDocTaskLinks now carries tick, title and
subtasks from the note to the task and adopts new checkboxes directly under the task;
SyncTaskToDoc carries tick, title and subtasks to the note and adds checkboxes for new subtasks.
Deleting is not mirrored. Deepening a task ties its subtasks right away; saves without links keep
the server's links. The sync response reports taskLinks/subtaskLinks; the editor shows an
"Unteraufgabe" badge. v0.36.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 12:29:10 +01:00
1 parent f91926c208
commit 5a8922011d
20 files changed
+511 -53

No files matched your search

+4
View File
@@ -39,6 +39,9 @@ type syncResponse struct {
Sessions int `json:"sessions"`
Presence []collab.Presence `json:"presence"`
Leases syncLeases `json:"leases"`
// Checkboxes linked to a task or to a subtask (block → task), for the editor's badges
TaskLinks map[string]string `json:"taskLinks"`
SubtaskLinks map[string]string `json:"subtaskLinks"`
}
// docAccess resolves the caller's role; it writes 404 and returns "" without access.
@@ -171,6 +174,7 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) {
resp.HeadSeq = head
resp.HasMore = len(entries) == syncMaxLog && entries[len(entries)-1].Seq < head
resp.Shared = s.store.IsDocShared(docID)
resp.TaskLinks, resp.SubtaskLinks = s.store.DocTaskLinks(docID)
resp.Sessions, resp.Presence = s.leases.Sessions(docID)
resp.Leases.Others = s.leases.Others(docID, req.ClientID)
+6
View File
@@ -205,6 +205,12 @@ func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Checkboxes already right under it become its subtasks (task_doc_sync.go)
if len(s.store.ReconcileDocTaskLinks(docID)) > 0 {
if fresh, err := s.store.GetThoughtByIDUnchecked(thought.ID); err == nil && fresh != nil {
thought = fresh
}
}
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
+1 -1
View File
@@ -320,7 +320,7 @@ func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Th
}
updated.Assignees = assignees
}
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
if docID := s.store.SyncTaskToDoc(userID, thoughtID); docID != "" {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
+3
View File
@@ -242,6 +242,8 @@ type SubTask struct {
Title string `json:"title"`
IsCompleted bool `json:"isCompleted"`
CreatedAt string `json:"createdAt,omitempty"`
// BlockID: the note checkbox this subtask is tied to, when its task is linked to a note
BlockID string `json:"blockId,omitempty"`
}
type Thought struct {
@@ -449,6 +451,7 @@ type DocumentBlock struct {
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"`
TaskID string `json:"taskId,omitempty"`
SubtaskOf string `json:"subtaskOf,omitempty"` // checkbox tied to a subtask of this task
}
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
-37
View File
@@ -443,43 +443,6 @@ func (s *Store) docTaskLinks(docID string) map[string]string {
return links
}
// ReconcileDocTaskLinks brings tasks linked to blocks in line with the note: a ticked block completes its
// task, a deleted block unlinks it (the task itself stays). Returns the IDs of changed tasks.
func (s *Store) ReconcileDocTaskLinks(docID string) []string {
links := s.docTaskLinks(docID)
if len(links) == 0 {
return nil
}
d, _, err := loadCollabDoc(s.db, docID)
if err != nil {
return nil
}
var changed []string
for blockID, taskID := range links {
b, ok := d.Blocks[blockID]
if !ok || b.Deleted {
if _, err := s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID); err == nil {
changed = append(changed, taskID)
}
continue
}
kanban := "todo"
if b.Props.Checked {
kanban = "done"
}
res, err := s.db.Exec(
`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND is_completed != ?`,
boolInt(b.Props.Checked), kanban, taskID, boolInt(b.Props.Checked))
if err == nil {
if n, _ := res.RowsAffected(); n > 0 {
changed = append(changed, taskID)
}
}
}
sort.Strings(changed)
return changed
}
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
return scanThought(s.db.QueryRow(
+2 -1
View File
@@ -12,9 +12,10 @@ import (
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 {
blocks := flatDocumentBlocks(docID, d)
if links := s.docTaskLinks(docID); len(links) > 0 {
if links, subLinks := s.DocTaskLinks(docID); len(links) > 0 {
for i := range blocks {
blocks[i].TaskID = links[blocks[i].ID]
blocks[i].SubtaskOf = subLinks[blocks[i].ID]
}
}
return blocks
+273
View File
@@ -0,0 +1,273 @@
package storage
import (
"encoding/json"
"strings"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/domain"
)
// A task linked to a note checkbox ("Erweitert") stays in step with it in both directions: tick and
// title. Its subtasks belong to the link as well: each subtask is tied 1:1 to one checkbox of the same
// note (SubTask.BlockID), wherever that checkbox is moved. New subtasks of the task appear as
// checkboxes right under the main checkbox; new checkboxes directly one level under the main checkbox
// become subtasks. Deleting is never mirrored: a deleted checkbox only loses its link, a deleted
// subtask leaves its checkbox in the note.
// docSubtaskLinks maps checkbox IDs of a note to the task whose subtask they are.
func (s *Store) docSubtaskLinks(docID string, mainLinks map[string]string) map[string]string {
out := map[string]string{}
for _, taskID := range mainLinks {
var raw string
if s.db.QueryRow(`SELECT subtasks FROM thoughts WHERE id = ?`, taskID).Scan(&raw) != nil {
continue
}
var subs []domain.SubTask
_ = json.Unmarshal([]byte(raw), &subs)
for _, st := range subs {
if st.BlockID != "" {
out[st.BlockID] = taskID
}
}
}
return out
}
// DocTaskLinks returns the note's checkboxes linked to a task (block → task) and those linked as a
// subtask (block → task of the subtask), for the editor's badges.
func (s *Store) DocTaskLinks(docID string) (tasks, subtasks map[string]string) {
tasks = s.docTaskLinks(docID)
return tasks, s.docSubtaskLinks(docID, tasks)
}
// reconcileSubtasks brings the subtasks of the task linked to mainID in line with the note: linked
// checkboxes give tick and title, gone or no longer checkbox blocks lose their link, new checkboxes
// directly under the main checkbox become subtasks (an unlinked subtask with the same title is linked
// instead of adding a duplicate). taken are checkboxes linked elsewhere (other tasks).
func reconcileSubtasks(d *collab.Doc, mainID string, subs []domain.SubTask, taken map[string]bool) ([]domain.SubTask, bool) {
changed := false
out := make([]domain.SubTask, len(subs))
copy(out, subs)
linked := map[string]bool{}
for i := range out {
id := out[i].BlockID
if id == "" {
continue
}
b, ok := d.Blocks[id]
if !ok || b.Deleted || b.Type != "todo" {
out[i].BlockID = ""
changed = true
continue
}
linked[id] = true
if b.Props.Checked != out[i].IsCompleted {
out[i].IsCompleted = b.Props.Checked
changed = true
}
if title := strings.TrimSpace(b.Content); title != "" && title != out[i].Title {
out[i].Title = title
changed = true
}
}
for _, child := range d.Children(mainID) {
if child.Deleted || child.Type != "todo" || linked[child.ID] || taken[child.ID] {
continue
}
title := strings.TrimSpace(child.Content)
if title == "" {
continue // an empty checkbox that is being typed into becomes a subtask once it has text
}
adopted := false
for i := range out {
if out[i].BlockID == "" && out[i].Title == title {
out[i].BlockID, out[i].IsCompleted = child.ID, child.Props.Checked
adopted = true
break
}
}
if !adopted {
out = append(out, domain.SubTask{ID: domain.NewID("st"), Title: title, IsCompleted: child.Props.Checked, CreatedAt: domain.NowISO(), BlockID: child.ID})
}
linked[child.ID] = true
changed = true
}
return out, changed
}
// ReconcileDocTaskLinks brings tasks linked to blocks in line with the note after it changed: tick,
// title and subtasks. A deleted main checkbox unlinks the task and its subtasks (all of them stay).
// Returns the IDs of changed tasks.
func (s *Store) ReconcileDocTaskLinks(docID string) []string {
links := s.docTaskLinks(docID)
if len(links) == 0 {
return nil
}
d, _, err := loadCollabDoc(s.db, docID)
if err != nil {
return nil
}
taken := map[string]bool{}
for blockID := range links {
taken[blockID] = true
}
var changed []string
for blockID, taskID := range links {
t, err := s.GetThoughtByIDUnchecked(taskID)
if err != nil || t == nil {
continue
}
b, ok := d.Blocks[blockID]
if !ok || b.Deleted {
for i := range t.Subtasks {
t.Subtasks[i].BlockID = ""
}
subsJSON, _ := json.Marshal(t.Subtasks)
if _, err := s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '', subtasks = ? WHERE id = ?`, string(subsJSON), taskID); err == nil {
changed = append(changed, taskID)
}
continue
}
update := false
content, done, kanban := t.Content, t.IsCompleted, t.KanbanStatus
if b.Props.Checked != t.IsCompleted {
done, update = b.Props.Checked, true
kanban = "todo"
if done {
kanban = "done"
}
}
if title := strings.TrimSpace(b.Content); title != "" && title != t.Content {
content, update = title, true
}
// Subtask checkboxes of other tasks in this note are not adopted
otherSubs := map[string]bool{}
for id, owner := range s.docSubtaskLinks(docID, links) {
if owner != taskID {
otherSubs[id] = true
}
}
for id := range taken {
otherSubs[id] = true
}
subs, subsChanged := reconcileSubtasks(d, blockID, t.Subtasks, otherSubs)
if !update && !subsChanged {
continue
}
tagsJSON, _ := json.Marshal(domain.ExtractTags(content))
subsJSON, _ := json.Marshal(subs)
if content == t.Content {
tagsJSON, _ = json.Marshal(t.Tags) // manual tags stay unless the title changed
}
if _, err := s.db.Exec(`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, kanban_status = ?, subtasks = ? WHERE id = ?`,
content, string(tagsJSON), boolInt(done), kanban, string(subsJSON), taskID); err == nil {
changed = append(changed, taskID)
}
}
return changed
}
// SyncTaskToDoc carries a linked task's tick, title and subtasks into its note: linked checkboxes get
// tick and title, subtasks without a checkbox get one at the end of the main checkbox's children (or
// take over an unlinked checkbox there with the same text). Returns the note ID if it changed.
func (s *Store) SyncTaskToDoc(userID, taskID string) string {
t, err := s.GetThoughtByIDUnchecked(taskID)
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
return ""
}
docID, mainID, _ := strings.Cut(t.ConvertedRefID, ":")
subs := make([]domain.SubTask, len(t.Subtasks))
copy(subs, t.Subtasks)
subsChanged := false
a := collab.Actor{UserID: userID, ClientID: "server"}
results, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op {
main, ok := d.Blocks[mainID]
if !ok || main.Deleted {
return nil
}
var ops []collab.Op
setBlock := func(b *collab.Block, checked bool, title string) {
if b.Props.Checked != checked {
c := checked
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpSetProps, BlockID: b.ID, Patch: &collab.PropsPatch{Checked: &c}})
}
if title != "" && strings.TrimSpace(b.Content) != title {
text := title
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: b.ID, BaseVersion: b.Version, Content: &text})
}
}
setBlock(main, t.IsCompleted, t.Content)
used := map[string]bool{mainID: true}
for _, st := range subs {
if st.BlockID != "" {
used[st.BlockID] = true
}
}
lastKey := ""
for _, c := range d.Children(mainID) {
lastKey = c.OrderKey
}
for i := range subs {
if b, ok := d.Blocks[subs[i].BlockID]; ok && subs[i].BlockID != "" {
if !b.Deleted && b.Type == "todo" {
setBlock(b, subs[i].IsCompleted, subs[i].Title)
}
continue
}
// Not in the note yet: take over an unlinked checkbox with the same text, else add one
for _, c := range d.Children(mainID) {
if !c.Deleted && c.Type == "todo" && !used[c.ID] && strings.TrimSpace(c.Content) == subs[i].Title {
subs[i].BlockID, used[c.ID], subsChanged = c.ID, true, true
setBlock(c, subs[i].IsCompleted, "")
break
}
}
if subs[i].BlockID != "" {
continue
}
key, err := collab.KeyBetween(lastKey, "")
if err != nil {
continue
}
lastKey = key
id := domain.NewID("blk")
parent, title := mainID, subs[i].Title
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: id, ParentID: &parent, OrderKey: key,
Type: "todo", Content: &title, Props: &collab.Props{Checked: subs[i].IsCompleted}})
subs[i].BlockID, used[id], subsChanged = id, true, true
}
return ops
})
if err != nil {
return ""
}
if subsChanged {
subsJSON, _ := json.Marshal(subs)
_, _ = s.db.Exec(`UPDATE thoughts SET subtasks = ? WHERE id = ?`, string(subsJSON), taskID)
}
for _, r := range results {
if r.Seq > 0 {
return docID
}
}
return ""
}
// keepSubtaskLinks: a client that saves subtasks without their checkbox link (an older state) keeps
// the links the server knows, matched by subtask ID.
func keepSubtaskLinks(before, after []domain.SubTask) []domain.SubTask {
known := map[string]string{}
for _, st := range before {
if st.BlockID != "" {
known[st.ID] = st.BlockID
}
}
for i := range after {
if after[i].BlockID == "" {
after[i].BlockID = known[after[i].ID]
}
}
return after
}
+153
View File
@@ -0,0 +1,153 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/domain"
)
// edit applies operations to a note like a client would.
func edit(t *testing.T, s *Store, userID, docID string, build func(d *collab.Doc) []collab.Op) {
t.Helper()
if _, _, err := s.applyDocChange(docID, collab.Actor{UserID: userID, ClientID: "test"}, nil, build); err != nil {
t.Fatal(err)
}
}
func lastChildKey(d *collab.Doc, parent string) string {
key := ""
for _, c := range d.Children(parent) {
key = c.OrderKey
}
return key
}
// Linked tasks follow their note in both directions: tick, title and subtasks (1:1, wherever the
// checkbox goes); new checkboxes directly under the task become subtasks; deleting is not mirrored.
func TestLinkedTaskAndSubtasksFollowTheNote(t *testing.T) {
s, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
th, _ := s.CreateThoughtWithDetails(anna.ID, "Umzug planen", "normal", "manual", "", "", "todo", "")
_, _ = s.UpdateThoughtTriage(anna.ID, th.ID, "keep", "task", "", "", "")
subs := []domain.SubTask{{ID: "st1", Title: "Kartons besorgen"}, {ID: "st2", Title: "Helfer fragen"}}
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
task, err := s.UpdateThoughtTriage(anna.ID, th.ID, "keep", "document", "", "", "")
if err != nil {
t.Fatal(err)
}
docID, mainID := task.DocID, task.BlockID
if task.Subtasks[0].BlockID == "" || task.Subtasks[1].BlockID == "" {
t.Fatalf("deepening should tie the subtasks to their checkboxes: %+v", task.Subtasks)
}
kartons := task.Subtasks[0].BlockID
if _, subLinks := s.DocTaskLinks(docID); subLinks[kartons] != th.ID {
t.Fatalf("subtask checkbox should be reported for the badge: %v", subLinks)
}
// Note → task: tick and title of a subtask, title of the main checkbox
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
yes := true
text := "Umzugskartons besorgen"
main := "Umzug nach Leipzig planen"
return []collab.Op{
{OpID: domain.NewID("op"), Kind: collab.OpSetProps, BlockID: kartons, Patch: &collab.PropsPatch{Checked: &yes}},
{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: kartons, BaseVersion: d.Blocks[kartons].Version, Content: &text},
{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: mainID, BaseVersion: d.Blocks[mainID].Version, Content: &main},
}
})
if changed := s.ReconcileDocTaskLinks(docID); len(changed) != 1 {
t.Fatalf("task should change: %v", changed)
}
got, _ := s.GetThoughtByID(anna.ID, th.ID)
if got.Content != "Umzug nach Leipzig planen" || !got.Subtasks[0].IsCompleted || got.Subtasks[0].Title != "Umzugskartons besorgen" {
t.Fatalf("note changes should reach the task: %+v", got)
}
// New checkbox directly under the main one → subtask; one level deeper → stays in the note only
var direct, deeper string
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
k1, _ := collab.KeyBetween(lastChildKey(d, mainID), "")
direct, deeper = domain.NewID("blk"), domain.NewID("blk")
p1, p2 := mainID, kartons
t1, t2 := "Strom ummelden", "Zählerstand fotografieren"
k2, _ := collab.KeyBetween("", "")
return []collab.Op{
{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: direct, ParentID: &p1, OrderKey: k1, Type: "todo", Content: &t1, Props: &collab.Props{}},
{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: deeper, ParentID: &p2, OrderKey: k2, Type: "todo", Content: &t2, Props: &collab.Props{}},
}
})
s.ReconcileDocTaskLinks(docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if len(got.Subtasks) != 3 || got.Subtasks[2].BlockID != direct || got.Subtasks[2].Title != "Strom ummelden" {
t.Fatalf("checkbox right under the task should become a subtask (and only that one): %+v", got.Subtasks)
}
// Moving a subtask checkbox elsewhere in the note keeps the link
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
top := ""
k, _ := collab.KeyBetween(lastChildKey(d, ""), "")
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpMove, BlockID: direct, ParentID: &top, OrderKey: k}}
})
s.ReconcileDocTaskLinks(docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if got.Subtasks[2].BlockID != direct {
t.Fatalf("a moved checkbox stays tied to its subtask: %+v", got.Subtasks)
}
// Task → note: tick and title of the main task and a subtask, a new subtask gets a checkbox
done := true
title := "Umzug nach Leipzig organisieren"
subs = append(got.Subtasks, domain.SubTask{ID: "st9", Title: "Nachsendeauftrag stellen"})
subs[1].IsCompleted = true
// An app with an older state sends the subtasks without their links: the server keeps them
for i := range subs {
subs[i].BlockID = ""
}
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, &title, &done, nil, nil, nil, nil, nil, &subs, nil)
if s.SyncTaskToDoc(anna.ID, th.ID) != docID {
t.Fatal("the note should change")
}
d, _, _ := loadCollabDoc(s.db, docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if d.Blocks[mainID].Content != title || !d.Blocks[mainID].Props.Checked {
t.Fatalf("main checkbox should follow the task: %+v", d.Blocks[mainID])
}
if !d.Blocks[got.Subtasks[1].BlockID].Props.Checked {
t.Fatal("subtask tick should reach its checkbox")
}
added := got.Subtasks[3].BlockID
if added == "" || d.Blocks[added].ParentID != mainID || d.Blocks[added].Content != "Nachsendeauftrag stellen" {
t.Fatalf("new subtask should get a checkbox under the task: %+v", got.Subtasks)
}
if len(d.Children(mainID)) != 3 { // the two original subtasks and the new one ("Strom ummelden" moved away)
t.Fatalf("no duplicate checkboxes expected: %d children", len(d.Children(mainID)))
}
// Deleting is not mirrored: a deleted checkbox only unlinks, a deleted subtask leaves its checkbox
helfer := got.Subtasks[1].BlockID
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: helfer}}
})
s.ReconcileDocTaskLinks(docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if len(got.Subtasks) != 4 || got.Subtasks[1].BlockID != "" {
t.Fatalf("subtask should stay, only unlinked: %+v", got.Subtasks)
}
remaining := append([]domain.SubTask{}, got.Subtasks[0], got.Subtasks[2], got.Subtasks[3])
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, nil, nil, nil, nil, nil, nil, nil, &remaining, nil)
s.SyncTaskToDoc(anna.ID, th.ID)
d, _, _ = loadCollabDoc(s.db, docID)
if b := d.Blocks[kartons]; b == nil || b.Deleted {
t.Fatal("deleting a subtask must not delete its checkbox")
}
// The unlinked "Helfer fragen" subtask was deleted in the task; nothing came back
if n := len(got.Subtasks); n != 4 {
t.Fatalf("unexpected subtasks: %d", n)
}
}
+10 -3
View File
@@ -338,14 +338,19 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
})
}
if len(thought.Subtasks) > 0 {
for _, st := range thought.Subtasks {
for i, st := range thought.Subtasks {
id := domain.NewID("blk")
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
ID: id,
Type: "todo",
Content: st.Title,
Checked: st.IsCompleted,
Level: 1,
})
// A task stays linked: its subtasks are tied to these checkboxes (task_doc_sync.go)
if isTask {
thought.Subtasks[i].BlockID = id
}
}
}
var metaItems []string
@@ -380,6 +385,8 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
newStatus = domain.StatusKept
convertedType = "document_block"
convertedRefID = doc.ID + ":" + blocks[0].ID
subsJSON, _ := json.Marshal(thought.Subtasks)
_, _ = s.db.Exec(`UPDATE thoughts SET subtasks = ? WHERE id = ?`, string(subsJSON), thought.ID)
}
}
} else if convertTo == "list_item" && targetListID != "" {
@@ -475,7 +482,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
thought.Notes = *notes
}
if subtasks != nil {
thought.Subtasks = *subtasks
thought.Subtasks = keepSubtaskLinks(thought.Subtasks, *subtasks)
}
tagsJSON, _ := json.Marshal(thought.Tags)