Merge dev into main (v0.36.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s

This commit is contained in:
sandrodach committed 2026-11-01 12:36:22 +01:00
commit 0a6777454e
20 files changed
+511 -53

No files matched your search

+7 -1
View File
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.36.0] - 2026-11-01
### Added
- Aufgaben, die mit einer Notiz verknüpft sind („Erweitert“), gleichen jetzt auch den Titel ab, in beide Richtungen. Ihre Unteraufgaben sind mit eigenen Häkchen in der Notiz verknüpft (Badge „Unteraufgabe“): Haken und Text wandern hin und her, auch wenn du das Häkchen in der Notiz verschiebst. Eine neue Unteraufgabe erscheint in der Notiz unter der Aufgabe; ein neues Häkchen direkt eine Ebene unter der Aufgabe wird zur Unteraufgabe. Gelöscht wird nie automatisch: Was du auf einer Seite löschst, bleibt auf der anderen stehen.
## [0.35.0] - 2026-11-01
### Added
@@ -606,7 +611,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...dev
[0.36.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...v0.36.0
[0.35.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.34.0...v0.35.0
[0.34.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.33.0...v0.34.0
[0.33.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.32.0...v0.33.0
+2 -1
View File
@@ -12,7 +12,8 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Farben in „Spalten anpassen“ über den Punkt vor dem Namen: Grau, Anthrazit (Textfarbe des Themes), Grün, Gelb, Blau, Rot, Lila, Akzentfarbe (`columns[].color` = `gray`/`slate`/`green`/`yellow`/`blue`/`red`/`purple`/`accent`; ältere Werte `emerald`/`amber` gelten als Grün/Gelb, Spalten ohne Farbe behalten die ihrer Rolle). Der Punkt erscheint im Spaltenkopf, vor dem Status in der Listenansicht und im Detail-Fenster ([ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx), `columnDotClass`). Blau und Lila kommen aus `--color-info` / `--color-purple`; Themes können sie setzen, sonst gelten Standardwerte aus `index.css`. Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
- **Erledigte aufräumen** ([done_cleanup.go](../../internal/storage/done_cleanup.go)): Erledigte Aufgaben werden standardmäßig 30 Tage nach dem Abhaken gelöscht. Die Frist stellt jede Person im Reiter „Erledigt“ ein (7, 30, 90, 365 Tage oder nie, `users.done_retention_days`); für Aufgaben in einer Liste gilt die Frist des Eigentümers, für persönliche die eigene. Den Zeitpunkt setzt ein Trigger (`thoughts.completed_at`), egal auf welchem Weg abgehakt wird; der Server räumt stündlich auf (nicht in der Demo). „Erledigte leeren“ (`POST /tasks/clear-done`) löscht sofort, aber nur, was man bearbeiten darf. Ein mit einer Notiz verknüpftes Häkchen bleibt dabei als normales Häkchen stehen.
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
- „Als Notiz vertiefen“ legt aus einer Aufgabe eine Notiz an (Titel, Notizen, Unteraufgaben, Fälligkeit, Priorität als Inhalt). Die Aufgabe bleibt in ihrer Liste und wird mit dem ersten Häkchen der Notiz verknüpft (`converted_type = document_block`); ein Gedanke aus der Triage wird dagegen ganz zur Notiz. Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
- „Als Notiz vertiefen“ legt aus einer Aufgabe eine Notiz an (Titel, Notizen, Unteraufgaben, Fälligkeit, Priorität als Inhalt). Die Aufgabe bleibt in ihrer Liste und wird mit dem ersten Häkchen der Notiz verknüpft (`converted_type = document_block`); ein Gedanke aus der Triage wird dagegen ganz zur Notiz. Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft, „In Notiz öffnen“ springt zum Block.
- **Abgleich verknüpfter Aufgaben** ([task_doc_sync.go](../../internal/storage/task_doc_sync.go)): Haken und Titel gehen in beide Richtungen (Notiz → Aufgabe in `ReconcileDocTaskLinks` nach jeder Notiz-Änderung, Aufgabe → Notiz in `SyncTaskToDoc` nach jeder Aufgaben-Änderung). Unteraufgaben gehören zur Verknüpfung: Jede ist 1:1 mit einem Häkchen derselben Notiz verbunden (`subtasks[].blockId`, Badge „Unteraufgabe“, `DocumentBlock.subtaskOf`), auch wenn es verschoben oder anders eingerückt wird. Neue Unteraufgaben bekommen ein Häkchen direkt unter dem Haupt-Häkchen; neue Häkchen direkt eine Ebene darunter werden Unteraufgaben (tiefer eingerückte bleiben Notiz). Löschen wird nicht abgeglichen: Ein gelöschtes Häkchen löst nur die Verknüpfung, eine gelöschte Unteraufgabe lässt ihr Häkchen stehen. Ein Häkchen, das in eine andere Notiz wandert, bekommt dort eine neue ID und verliert damit die Verknüpfung. Die Sync-Antwort der Notiz liefert `taskLinks` und `subtaskLinks`, damit die Badges sofort stimmen.
## Aufgabenlisten und Freigabe
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.35.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.36.0-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.35.0",
"version": "0.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.35.0",
"version": "0.36.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.35.0",
"version": "0.36.0",
"license": "Unlicense",
"type": "module",
"scripts": {
@@ -128,6 +128,7 @@ function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean {
!!x.collapsed !== !!y.collapsed ||
(x.lang || '') !== (y.lang || '') ||
(x.taskId || '') !== (y.taskId || '') ||
(x.subtaskOf || '') !== (y.subtaskOf || '') ||
(x.conflictOf || '') !== (y.conflictOf || '')
) {
return false;
@@ -360,10 +361,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
setBlocks(normalized);
}
const links = new Map<string, string>();
const subtaskLinks = new Map<string, string>();
for (const b of fetched.blocks || []) {
if (b.taskId) links.set(b.id, b.taskId);
if (b.subtaskOf) subtaskLinks.set(b.id, b.subtaskOf);
}
engineRef.current?.setTaskLinks(links);
engineRef.current?.setTaskLinks(links, subtaskLinks);
setLoading(false);
onDocMetadataChange(fetched);
@@ -1488,6 +1488,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</button>
</div>
)}
{/* Checkbox tied to a subtask of a linked task: tick and text follow the subtask */}
{block.type === 'todo' && !block.taskId && block.subtaskOf && (
<div className="flex items-center gap-1 mt-0.5 select-none">
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.subtaskOf as string);
}}
className="badge-neutral text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
title="Unteraufgabe einer verknüpften Aufgabe – Aufgabe öffnen"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Unteraufgabe</span>
</button>
</div>
)}
{/* Conflict copy: text that lost against someone else's change */}
{block.conflictOf && (
@@ -1619,7 +1637,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
{block.subtaskOf && !block.taskId ? (
<p className="px-2 py-1.5 text-faint">Unteraufgabe einer verknüpften Aufgabe</p>
) : !block.taskId ? (
<button
type="button"
onClick={async () => {
+7 -1
View File
@@ -121,7 +121,12 @@ export function diffFlat(d: Doc, blocks: DocumentBlock[], newOpId: () => string)
}
/** Converts the tree into the flat list used by the editor components. */
export function toDocumentBlocks(d: Doc, docId: string, taskLinks?: Map<string, string>): DocumentBlock[] {
export function toDocumentBlocks(
d: Doc,
docId: string,
taskLinks?: Map<string, string>,
subtaskLinks?: Map<string, string>
): DocumentBlock[] {
return flatten(d).map((b, i) => ({
id: b.id,
documentId: docId,
@@ -133,6 +138,7 @@ export function toDocumentBlocks(d: Doc, docId: string, taskLinks?: Map<string,
level: b.level,
position: (i + 1) * 1000,
taskId: taskLinks?.get(b.id),
subtaskOf: subtaskLinks?.get(b.id),
conflictOf: b.props.conflictOf,
conflictAuthor: b.props.conflictAuthor,
}));
+7 -2
View File
@@ -133,6 +133,7 @@ export class DocSyncEngine {
private lockedByOthers: LeaseInfo[] = [];
private conflicts: Result[] = [];
private taskLinks = new Map<string, string>();
private subtaskLinks = new Map<string, string>();
private focused: string | null = null;
private activity = new Map<string, number>(); // blockId -> last edit/focus time
@@ -234,7 +235,7 @@ export class DocSyncEngine {
errorMessage: this.errorMessage,
role: this.role,
title: this.local.title,
blocks: toDocumentBlocks(this.local, this.docId, this.taskLinks),
blocks: toDocumentBlocks(this.local, this.docId, this.taskLinks, this.subtaskLinks),
pendingCount: this.pending.length + (this.inflight?.length || 0),
shared: this.shared,
sessions: this.sessions,
@@ -252,8 +253,9 @@ export class DocSyncEngine {
}
/** Task IDs of blocks linked to tasks (kept outside the synced tree). */
setTaskLinks(links: Map<string, string>): void {
setTaskLinks(links: Map<string, string>, subtaskLinks?: Map<string, string>): void {
this.taskLinks = new Map(links);
if (subtaskLinks) this.subtaskLinks = new Map(subtaskLinks);
this.emit();
}
@@ -562,6 +564,9 @@ export class DocSyncEngine {
this.role = res.role;
this.shared = res.shared;
this.sessions = res.sessions;
// The server reports the links after every sync, so badges follow subtasks it adopted or added
if (res.taskLinks) this.taskLinks = new Map(Object.entries(res.taskLinks));
if (res.subtaskLinks) this.subtaskLinks = new Map(Object.entries(res.subtaskLinks));
this.presence = res.presence || [];
this.lockedByOthers = res.leases?.others || [];
+3
View File
@@ -51,6 +51,9 @@ export interface SyncResponse {
sessions: number;
presence: Presence[];
leases: { granted: string[]; denied: string[]; others: LeaseInfo[] };
// Checkboxes linked to a task / to a subtask (block → task), for the badges
taskLinks?: Record<string, string>;
subtaskLinks?: Record<string, string>;
}
/** status 0 = network error or timeout; otherwise the HTTP status. */
+4
View File
@@ -138,6 +138,8 @@ export interface SubTask {
title: string;
isCompleted: boolean;
createdAt?: string;
// Note checkbox this subtask is tied to (its task is linked to a note); kept by the server
blockId?: string;
}
// Repeat rule of a task: completing it creates the next one (due date one interval later)
@@ -315,6 +317,8 @@ export interface DocumentBlock {
docId?: string;
documentId?: string;
taskId?: string;
// Checkbox tied to a subtask of this task (the task itself is linked to another checkbox)
subtaskOf?: string;
type: BlockType;
content: string;
checked?: boolean;
+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)