vikunja/veans/internal/commands/update.go

269 lines
8.6 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package commands
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/spf13/cobra"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/output"
"code.vikunja.io/veans/internal/status"
)
type updateFlags struct {
statusName string
title string
priority int64
priorityIsSet bool
addLabels []string
removeLabels []string
description string
descriptionIsSet bool
replaceOld string
replaceNew string
descriptionApp string
comment string
reason string
ifUnchangedSince string
}
func newUpdateCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "update <id>",
Aliases: []string{"u"},
Short: "Update a task by PROJ-NN, #NN, or numeric ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := loadRuntime()
if err != nil {
return err
}
f.descriptionIsSet = cmd.Flags().Changed("description")
f.priorityIsSet = cmd.Flags().Changed("priority")
id, err := rt.resolveTaskID(cmd.Context(), args[0])
if err != nil {
return err
}
task, err := runUpdate(cmd.Context(), rt, id, f)
if err != nil {
return err
}
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
},
}
cmd.Flags().StringVarP(&f.statusName, "status", "s", "", "transition to a status")
cmd.Flags().StringVarP(&f.title, "title", "t", "", "new title")
cmd.Flags().Int64Var(&f.priority, "priority", 0, "new priority")
cmd.Flags().StringSliceVar(&f.addLabels, "label-add", nil, "labels to attach (repeatable; veans: prefix added if missing)")
cmd.Flags().StringSliceVar(&f.removeLabels, "label-remove", nil, "labels to detach (repeatable)")
cmd.Flags().StringVar(&f.description, "description", "", "replace the entire description")
cmd.Flags().StringVar(&f.replaceOld, "description-replace-old", "", "exact-match string to replace in description (must be unique)")
cmd.Flags().StringVar(&f.replaceNew, "description-replace-new", "", "replacement for --description-replace-old")
cmd.Flags().StringVar(&f.descriptionApp, "description-append", "", "append text to the existing description")
cmd.Flags().StringVarP(&f.comment, "comment", "c", "", "post a comment as part of this update")
cmd.Flags().StringVar(&f.reason, "reason", "", "rationale (required when --status scrapped)")
cmd.Flags().StringVar(&f.ifUnchangedSince, "if-unchanged-since", "", "RFC3339 timestamp; abort if the task has changed since")
return cmd
}
// runUpdate is intentionally a single linear flow — the steps it performs
// (concurrency check → status → field changes → comments → field POST →
// bucket move → label add/remove → refetch) all share the same task,
// flag set, and error-handling shape. Splitting them produces five tiny
// functions that each take the same five arguments.
//
//nolint:gocyclo // single-pass orchestration; each branch is one short stanza
func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*client.Task, error) {
current, err := rt.client.GetTask(ctx, id)
if err != nil {
return nil, err
}
// Optimistic concurrency.
if f.ifUnchangedSince != "" {
ts, err := time.Parse(time.RFC3339, f.ifUnchangedSince)
if err != nil {
return nil, output.Wrap(output.CodeValidation, err, "parse --if-unchanged-since: %v", err)
}
if current.Updated.After(ts) {
return nil, output.New(output.CodeConflict,
"task %s changed at %s, after --if-unchanged-since %s",
rt.cfg.FormatTaskID(current.Index), current.Updated.Format(time.RFC3339), ts.Format(time.RFC3339))
}
}
// Resolve new status / done flag if --status is set.
var newStatus status.Status
if f.statusName != "" {
s, err := status.Parse(f.statusName)
if err != nil {
return nil, err
}
newStatus = s
if s == status.Scrapped && strings.TrimSpace(f.reason) == "" {
return nil, output.New(output.CodeValidation, "--reason is required when --status scrapped")
}
}
// Build the update payload incrementally so we don't clobber unmentioned
// fields. The base must include the ID; bucket/done are conditional.
body := &client.Task{ID: id}
dirty := false
if f.title != "" {
body.Title = f.title
dirty = true
}
if f.priorityIsSet {
body.Priority = f.priority
dirty = true
}
// Description ops are mutually-exclusive layers; --description wins
// outright, otherwise replace-old/new + append run on the current body.
newDesc, descChanged, err := composeDescription(current.Description, f)
if err != nil {
return nil, err
}
if descChanged {
body.Description = newDesc
dirty = true
}
// Status transitions: `done` is set on the task body (Update processes
// it natively), but the bucket move uses the dedicated TaskBucket
// endpoint after the field update so the change is visible on the
// Kanban view.
var bucketTransitionTarget int64
if newStatus != "" {
bid, err := status.BucketID(newStatus, rt.cfg.Buckets)
if err != nil {
return nil, err
}
bucketTransitionTarget = bid
body.Done = newStatus.Done()
dirty = true
}
// Comment first when transitioning to scrapped — the reason is part of
// the audit trail and should appear before the bucket move in the log.
if newStatus == status.Scrapped {
if _, err := rt.client.AddTaskComment(ctx, id, "<strong>Scrapped:</strong> "+strings.TrimSpace(f.reason)); err != nil {
return nil, err
}
}
if f.comment != "" {
if _, err := rt.client.AddTaskComment(ctx, id, f.comment); err != nil {
return nil, err
}
}
// Apply the field update if anything changed.
updated := current
if dirty {
u, err := rt.client.UpdateTask(ctx, id, body)
if err != nil {
return nil, err
}
updated = u
}
// Move the task between buckets after the field update.
if bucketTransitionTarget != 0 {
if err := rt.client.MoveTaskToBucket(ctx,
rt.cfg.ProjectID, rt.cfg.ViewID, bucketTransitionTarget, id); err != nil {
return nil, err
}
}
// Label add/remove run after the field update so a status transition
// can't clobber freshly-attached labels.
for _, raw := range f.addLabels {
title := normalizeLabelTitle(raw)
l, err := getOrCreateLabelByTitle(ctx, rt.client, title)
if err != nil {
return nil, err
}
if err := rt.client.AddLabelToTask(ctx, id, l.ID); err != nil {
return nil, err
}
}
for _, raw := range f.removeLabels {
title := normalizeLabelTitle(raw)
if l := findLabelOnTask(updated, title); l != nil {
if err := rt.client.RemoveLabelFromTask(ctx, id, l.ID); err != nil {
return nil, err
}
}
}
if len(f.addLabels) > 0 || len(f.removeLabels) > 0 || bucketTransitionTarget != 0 {
fresh, err := rt.client.GetTask(ctx, id)
if err == nil {
updated = fresh
}
}
return updated, nil
}
// composeDescription folds --description / --description-replace-* / --description-append
// into the existing body. Returns (new, changed, error).
func composeDescription(existing string, f *updateFlags) (string, bool, error) {
if f.descriptionIsSet {
// --description replaces wholesale.
return f.description, true, nil
}
out := existing
changed := false
if f.replaceOld != "" || f.replaceNew != "" {
if f.replaceOld == "" {
return "", false, output.New(output.CodeValidation, "--description-replace-new requires --description-replace-old")
}
count := strings.Count(out, f.replaceOld)
switch {
case count == 0:
return "", false, output.New(output.CodeValidation,
"--description-replace-old not found in description")
case count > 1:
return "", false, output.New(output.CodeValidation,
"--description-replace-old matched %d times — make it unique", count)
}
out = strings.Replace(out, f.replaceOld, f.replaceNew, 1)
changed = true
}
if f.descriptionApp != "" {
if out != "" && !strings.HasSuffix(out, "\n") {
out += "\n"
}
out += f.descriptionApp
changed = true
}
return out, changed, nil
}