fix: use Modifier.Set() to maintain AttributeOrder invariant in API handlers

API handlers were populating SetAttributes directly without appending to
AttributeOrder. Since Apply() only iterates AttributeOrder, all updates
via PUT/POST were silently dropped — causing edits to revert and tasks
to disappear on reload.

Adds Modifier.Set() helper, safety net in Apply()/ApplyToNew(), adds
description and status to applyNonDateAttribute, and fixes the
recurrence->recur key mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 22:42:57 +01:00
parent 201f32d095
commit 393b7a144a
3 changed files with 57 additions and 25 deletions
+16 -16
View File
@@ -130,35 +130,35 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
mod.AddTags = req.Tags mod.AddTags = req.Tags
if req.Project != nil { if req.Project != nil {
mod.SetAttributes["project"] = req.Project mod.Set("project", req.Project)
} }
if req.Priority != nil { if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority mod.Set("priority", req.Priority)
} }
if req.Due != nil { if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due) dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr mod.Set("due", &dueStr)
} }
if req.Scheduled != nil { if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled) scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr mod.Set("scheduled", &scheduledStr)
} }
if req.Wait != nil { if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait) waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr mod.Set("wait", &waitStr)
} }
if req.Until != nil { if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until) untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr mod.Set("until", &untilStr)
} }
if req.Recurrence != nil { if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence mod.Set("recur", req.Recurrence)
} }
// Create task // Create task
@@ -232,39 +232,39 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
mod := engine.NewModifier() mod := engine.NewModifier()
if req.Description != nil { if req.Description != nil {
mod.SetAttributes["description"] = req.Description mod.Set("description", req.Description)
} }
if req.Status != nil { if req.Status != nil {
mod.SetAttributes["status"] = req.Status mod.Set("status", req.Status)
} }
if req.Priority != nil { if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority mod.Set("priority", req.Priority)
} }
if req.Project != nil { if req.Project != nil {
mod.SetAttributes["project"] = req.Project mod.Set("project", req.Project)
} }
if req.Due != nil { if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due) dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr mod.Set("due", &dueStr)
} }
if req.Scheduled != nil { if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled) scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr mod.Set("scheduled", &scheduledStr)
} }
if req.Wait != nil { if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait) waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr mod.Set("wait", &waitStr)
} }
if req.Until != nil { if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until) untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr mod.Set("until", &untilStr)
} }
if req.Start != nil { if req.Start != nil {
@@ -273,7 +273,7 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
} }
if req.Recurrence != nil { if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence mod.Set("recur", req.Recurrence)
} }
// Apply modifier // Apply modifier
+1
View File
@@ -4,6 +4,7 @@ package engine
// Used by parseAddArgs (cmd/add.go), ParseFilter, and ParseModifier // Used by parseAddArgs (cmd/add.go), ParseFilter, and ParseModifier
// to distinguish modifiers from description text. // to distinguish modifiers from description text.
var ValidAttributeKeys = map[string]bool{ var ValidAttributeKeys = map[string]bool{
"description": true,
"due": true, "due": true,
"priority": true, "priority": true,
"project": true, "project": true,
+32 -1
View File
@@ -23,6 +23,13 @@ func NewModifier() *Modifier {
} }
} }
// Set adds an attribute to the modifier, maintaining the SetAttributes and
// AttributeOrder invariant. Pass nil to clear the attribute.
func (m *Modifier) Set(key string, value *string) {
m.SetAttributes[key] = value
m.AttributeOrder = append(m.AttributeOrder, key)
}
// ParseModifier parses command-line args into Modifier. // ParseModifier parses command-line args into Modifier.
// Only recognized attribute keys (ValidAttributeKeys) are accepted; // Only recognized attribute keys (ValidAttributeKeys) are accepted;
// unrecognized key:value tokens produce an error. // unrecognized key:value tokens produce an error.
@@ -86,6 +93,14 @@ func (m *Modifier) Apply(task *Task) error {
resolvedDates["created"] = task.Created resolvedDates["created"] = task.Created
resolvedDates["modified"] = task.Modified resolvedDates["modified"] = task.Modified
// Safety net: if SetAttributes were populated without AttributeOrder,
// reconstruct order from map keys so updates aren't silently dropped.
if len(m.AttributeOrder) == 0 && len(m.SetAttributes) > 0 {
for key := range m.SetAttributes {
m.AttributeOrder = append(m.AttributeOrder, key)
}
}
// Apply attributes in the order they were specified (important for relative references) // Apply attributes in the order they were specified (important for relative references)
dateKeys := DateKeys dateKeys := DateKeys
@@ -134,6 +149,14 @@ func (m *Modifier) ApplyToNew(task *Task) error {
resolvedDates["created"] = task.Created resolvedDates["created"] = task.Created
} }
// Safety net: if SetAttributes were populated without AttributeOrder,
// reconstruct order from map keys so updates aren't silently dropped.
if len(m.AttributeOrder) == 0 && len(m.SetAttributes) > 0 {
for key := range m.SetAttributes {
m.AttributeOrder = append(m.AttributeOrder, key)
}
}
// Apply attributes in the order they were specified (important for relative references) // Apply attributes in the order they were specified (important for relative references)
dateKeys := DateKeys dateKeys := DateKeys
@@ -156,9 +179,17 @@ func (m *Modifier) ApplyToNew(task *Task) error {
return nil return nil
} }
// applyNonDateAttribute applies a non-date attribute (priority, project, recur) to a task. // applyNonDateAttribute applies a non-date attribute to a task.
func applyNonDateAttribute(key string, valuePtr *string, task *Task) error { func applyNonDateAttribute(key string, valuePtr *string, task *Task) error {
switch key { switch key {
case "description":
if valuePtr != nil {
task.Description = *valuePtr
}
case "status":
if valuePtr != nil && len(*valuePtr) > 0 {
task.Status = Status((*valuePtr)[0])
}
case "priority": case "priority":
if valuePtr == nil { if valuePtr == nil {
task.Priority = PriorityDefault task.Priority = PriorityDefault