// 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 . 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 ", 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, "Scrapped: "+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 }