393b7a144a
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>
299 lines
7.8 KiB
Go
299 lines
7.8 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Modifier struct {
|
|
SetAttributes map[string]*string // key -> value (nil = clear)
|
|
AttributeOrder []string // Track order of attributes for relative references
|
|
AddTags []string
|
|
RemoveTags []string
|
|
}
|
|
|
|
func NewModifier() *Modifier {
|
|
return &Modifier{
|
|
SetAttributes: make(map[string]*string),
|
|
AttributeOrder: []string{},
|
|
AddTags: []string{},
|
|
RemoveTags: []string{},
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// Only recognized attribute keys (ValidAttributeKeys) are accepted;
|
|
// unrecognized key:value tokens produce an error.
|
|
func ParseModifier(args []string) (*Modifier, error) {
|
|
m := NewModifier()
|
|
|
|
for _, arg := range args {
|
|
if strings.HasPrefix(arg, "+") {
|
|
// Add tag
|
|
m.AddTags = append(m.AddTags, strings.TrimPrefix(arg, "+"))
|
|
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
|
|
// Remove tag
|
|
m.RemoveTags = append(m.RemoveTags, strings.TrimPrefix(arg, "-"))
|
|
} else if idx := strings.Index(arg, ":"); idx > 0 {
|
|
key := arg[:idx]
|
|
value := arg[idx+1:]
|
|
|
|
if !ValidAttributeKeys[key] {
|
|
return nil, fmt.Errorf("unknown modifier: %q (known: due, priority, project, recur, status, wait, scheduled, until)", key)
|
|
}
|
|
|
|
if value == "" {
|
|
// Clear attribute (priority: with no value)
|
|
m.SetAttributes[key] = nil
|
|
} else {
|
|
m.SetAttributes[key] = &value
|
|
}
|
|
|
|
// Track order for relative date references
|
|
m.AttributeOrder = append(m.AttributeOrder, key)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// Apply applies modifier to task
|
|
func (m *Modifier) Apply(task *Task) error {
|
|
// Track resolved dates for relative references
|
|
resolvedDates := make(map[string]time.Time)
|
|
|
|
// Initialize with existing task dates
|
|
if task.Due != nil {
|
|
resolvedDates["due"] = *task.Due
|
|
}
|
|
if task.Scheduled != nil {
|
|
resolvedDates["scheduled"] = *task.Scheduled
|
|
}
|
|
if task.Wait != nil {
|
|
resolvedDates["wait"] = *task.Wait
|
|
}
|
|
if task.Until != nil {
|
|
resolvedDates["until"] = *task.Until
|
|
}
|
|
if task.Start != nil {
|
|
resolvedDates["start"] = *task.Start
|
|
}
|
|
if task.End != nil {
|
|
resolvedDates["end"] = *task.End
|
|
}
|
|
resolvedDates["created"] = task.Created
|
|
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)
|
|
dateKeys := DateKeys
|
|
|
|
for _, key := range m.AttributeOrder {
|
|
valuePtr := m.SetAttributes[key]
|
|
|
|
if dateKeys[key] {
|
|
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := applyNonDateAttribute(key, valuePtr, task); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, tag := range m.AddTags {
|
|
if err := task.AddTag(tag); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, tag := range m.RemoveTags {
|
|
if err := task.RemoveTag(tag); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
task.Modified = timeNow()
|
|
|
|
return task.Save()
|
|
}
|
|
|
|
// ApplyToNew applies modifier to a new task (before it's saved)
|
|
// This is used when creating tasks with modifiers
|
|
func (m *Modifier) ApplyToNew(task *Task) error {
|
|
// Track resolved dates for relative references
|
|
resolvedDates := make(map[string]time.Time)
|
|
|
|
// Initialize with existing task dates (usually empty for new tasks)
|
|
if task.Created.IsZero() {
|
|
resolvedDates["created"] = timeNow()
|
|
} else {
|
|
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)
|
|
dateKeys := DateKeys
|
|
|
|
for _, key := range m.AttributeOrder {
|
|
valuePtr := m.SetAttributes[key]
|
|
|
|
if dateKeys[key] {
|
|
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := applyNonDateAttribute(key, valuePtr, task); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Note: Tags are added after task is saved (in CreateTask function)
|
|
return nil
|
|
}
|
|
|
|
// applyNonDateAttribute applies a non-date attribute to a task.
|
|
func applyNonDateAttribute(key string, valuePtr *string, task *Task) error {
|
|
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":
|
|
if valuePtr == nil {
|
|
task.Priority = PriorityDefault
|
|
} else {
|
|
task.Priority = Priority(priorityStringToInt(*valuePtr))
|
|
}
|
|
case "project":
|
|
task.Project = valuePtr
|
|
case "recur":
|
|
if valuePtr == nil {
|
|
task.RecurrenceDuration = nil
|
|
} else {
|
|
duration, err := ParseRecurrencePattern(*valuePtr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid recurrence: %w", err)
|
|
}
|
|
task.RecurrenceDuration = &duration
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseRelativeExpression checks if a string is a relative date expression
|
|
// Returns: baseAttr, operator, offset, isRelative
|
|
// Example: "due-1d" -> "due", "-", "1d", true
|
|
func parseRelativeExpression(s string) (string, string, string, bool) {
|
|
// Pattern: <attribute><operator><duration>
|
|
// Examples: due-1d, scheduled+2w, wait-3days
|
|
re := regexp.MustCompile(`^([a-z_]+)([\+\-])(.+)$`)
|
|
matches := re.FindStringSubmatch(s)
|
|
|
|
if len(matches) == 4 {
|
|
return matches[1], matches[2], matches[3], true
|
|
}
|
|
return "", "", "", false
|
|
}
|
|
|
|
// resolveDateValue resolves a date value, which may be absolute or relative
|
|
// resolvedDates contains dates that have been resolved so far (for relative references)
|
|
func resolveDateValue(valueStr string, resolvedDates map[string]time.Time) (time.Time, error) {
|
|
// Check if it's a relative expression
|
|
if baseAttr, op, offsetStr, isRelative := parseRelativeExpression(valueStr); isRelative {
|
|
// Look up the base date
|
|
baseDate, exists := resolvedDates[baseAttr]
|
|
if !exists || baseDate.IsZero() {
|
|
return time.Time{}, fmt.Errorf("cannot reference '%s' in '%s' before it's set", baseAttr, valueStr)
|
|
}
|
|
|
|
// Parse the offset duration
|
|
duration, err := ParseRecurrencePattern(offsetStr)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid offset in '%s': %w", valueStr, err)
|
|
}
|
|
|
|
// Apply the operation
|
|
if op == "+" {
|
|
return baseDate.Add(duration), nil
|
|
} else {
|
|
return baseDate.Add(-duration), nil
|
|
}
|
|
}
|
|
|
|
// Not relative, parse as absolute date
|
|
return ParseDate(valueStr)
|
|
}
|
|
|
|
// applyDateAttribute applies a date attribute with support for relative expressions
|
|
func applyDateAttribute(key string, valuePtr *string, task *Task, resolvedDates map[string]time.Time) error {
|
|
if valuePtr == nil {
|
|
// Clear the date
|
|
switch key {
|
|
case "due":
|
|
task.Due = nil
|
|
case "scheduled":
|
|
task.Scheduled = nil
|
|
case "wait":
|
|
task.Wait = nil
|
|
case "until":
|
|
task.Until = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resolve the date (may be relative)
|
|
parsed, err := resolveDateValue(*valuePtr, resolvedDates)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid %s date: %w", key, err)
|
|
}
|
|
|
|
// Set the date on the task
|
|
switch key {
|
|
case "due":
|
|
task.Due = &parsed
|
|
case "scheduled":
|
|
task.Scheduled = &parsed
|
|
case "wait":
|
|
task.Wait = &parsed
|
|
case "until":
|
|
task.Until = &parsed
|
|
}
|
|
|
|
// Store in resolved dates for future relative references
|
|
resolvedDates[key] = parsed
|
|
|
|
return nil
|
|
}
|