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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user