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
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:
1 parent
f91926c208
commit
5a8922011d
20 files changed
+511
-53
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user