github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/gist/edit/edit.go (about)

     1  package edit
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"os"
    11  	"path/filepath"
    12  	"sort"
    13  	"strings"
    14  
    15  	"github.com/AlecAivazis/survey/v2"
    16  	"github.com/ungtb10d/cli/v2/api"
    17  	"github.com/ungtb10d/cli/v2/internal/config"
    18  	"github.com/ungtb10d/cli/v2/pkg/cmd/gist/shared"
    19  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    20  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    21  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    22  	"github.com/ungtb10d/cli/v2/pkg/surveyext"
    23  	"github.com/spf13/cobra"
    24  )
    25  
    26  type EditOptions struct {
    27  	IO         *iostreams.IOStreams
    28  	HttpClient func() (*http.Client, error)
    29  	Config     func() (config.Config, error)
    30  
    31  	Edit func(string, string, string, *iostreams.IOStreams) (string, error)
    32  
    33  	Selector     string
    34  	EditFilename string
    35  	AddFilename  string
    36  	SourceFile   string
    37  	Description  string
    38  }
    39  
    40  func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command {
    41  	opts := EditOptions{
    42  		IO:         f.IOStreams,
    43  		HttpClient: f.HttpClient,
    44  		Config:     f.Config,
    45  		Edit: func(editorCmd, filename, defaultContent string, io *iostreams.IOStreams) (string, error) {
    46  			return surveyext.Edit(
    47  				editorCmd,
    48  				"*."+filename,
    49  				defaultContent,
    50  				io.In, io.Out, io.ErrOut)
    51  		},
    52  	}
    53  
    54  	cmd := &cobra.Command{
    55  		Use:   "edit {<id> | <url>} [<filename>]",
    56  		Short: "Edit one of your gists",
    57  		Args: func(cmd *cobra.Command, args []string) error {
    58  			if len(args) < 1 {
    59  				return cmdutil.FlagErrorf("cannot edit: gist argument required")
    60  			}
    61  			if len(args) > 2 {
    62  				return cmdutil.FlagErrorf("too many arguments")
    63  			}
    64  			return nil
    65  		},
    66  		RunE: func(c *cobra.Command, args []string) error {
    67  			opts.Selector = args[0]
    68  			if len(args) > 1 {
    69  				opts.SourceFile = args[1]
    70  			}
    71  
    72  			if runF != nil {
    73  				return runF(&opts)
    74  			}
    75  
    76  			return editRun(&opts)
    77  		},
    78  	}
    79  
    80  	cmd.Flags().StringVarP(&opts.AddFilename, "add", "a", "", "Add a new file to the gist")
    81  	cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "New description for the gist")
    82  	cmd.Flags().StringVarP(&opts.EditFilename, "filename", "f", "", "Select a file to edit")
    83  
    84  	return cmd
    85  }
    86  
    87  func editRun(opts *EditOptions) error {
    88  	gistID := opts.Selector
    89  
    90  	if strings.Contains(gistID, "/") {
    91  		id, err := shared.GistIDFromURL(gistID)
    92  		if err != nil {
    93  			return err
    94  		}
    95  		gistID = id
    96  	}
    97  
    98  	client, err := opts.HttpClient()
    99  	if err != nil {
   100  		return err
   101  	}
   102  
   103  	apiClient := api.NewClientFromHTTP(client)
   104  
   105  	cfg, err := opts.Config()
   106  	if err != nil {
   107  		return err
   108  	}
   109  
   110  	host, _ := cfg.DefaultHost()
   111  
   112  	gist, err := shared.GetGist(client, host, gistID)
   113  	if err != nil {
   114  		if errors.Is(err, shared.NotFoundErr) {
   115  			return fmt.Errorf("gist not found: %s", gistID)
   116  		}
   117  		return err
   118  	}
   119  
   120  	username, err := api.CurrentLoginName(apiClient, host)
   121  	if err != nil {
   122  		return err
   123  	}
   124  
   125  	if username != gist.Owner.Login {
   126  		return errors.New("you do not own this gist")
   127  	}
   128  
   129  	shouldUpdate := false
   130  	if opts.Description != "" {
   131  		shouldUpdate = true
   132  		gist.Description = opts.Description
   133  	}
   134  
   135  	if opts.AddFilename != "" {
   136  		var input io.Reader
   137  		switch src := opts.SourceFile; {
   138  		case src == "-":
   139  			input = opts.IO.In
   140  		case src != "":
   141  			f, err := os.Open(src)
   142  			if err != nil {
   143  				return err
   144  			}
   145  			defer func() {
   146  				_ = f.Close()
   147  			}()
   148  			input = f
   149  		default:
   150  			f, err := os.Open(opts.AddFilename)
   151  			if err != nil {
   152  				return err
   153  			}
   154  			defer func() {
   155  				_ = f.Close()
   156  			}()
   157  			input = f
   158  		}
   159  
   160  		content, err := io.ReadAll(input)
   161  		if err != nil {
   162  			return fmt.Errorf("read content: %w", err)
   163  		}
   164  
   165  		files, err := getFilesToAdd(opts.AddFilename, content)
   166  		if err != nil {
   167  			return err
   168  		}
   169  
   170  		gist.Files = files
   171  		return updateGist(apiClient, host, gist)
   172  	}
   173  
   174  	filesToUpdate := map[string]string{}
   175  
   176  	for {
   177  		filename := opts.EditFilename
   178  		candidates := []string{}
   179  		for filename := range gist.Files {
   180  			candidates = append(candidates, filename)
   181  		}
   182  
   183  		sort.Strings(candidates)
   184  
   185  		if filename == "" {
   186  			if len(candidates) == 1 {
   187  				filename = candidates[0]
   188  			} else {
   189  				if !opts.IO.CanPrompt() {
   190  					return errors.New("unsure what file to edit; either specify --filename or run interactively")
   191  				}
   192  				//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   193  				err = prompt.SurveyAskOne(&survey.Select{
   194  					Message: "Edit which file?",
   195  					Options: candidates,
   196  				}, &filename)
   197  
   198  				if err != nil {
   199  					return fmt.Errorf("could not prompt: %w", err)
   200  				}
   201  			}
   202  		}
   203  
   204  		gistFile, found := gist.Files[filename]
   205  		if !found {
   206  			return fmt.Errorf("gist has no file %q", filename)
   207  		}
   208  		if shared.IsBinaryContents([]byte(gistFile.Content)) {
   209  			return fmt.Errorf("editing binary files not supported")
   210  		}
   211  
   212  		var text string
   213  		if src := opts.SourceFile; src != "" {
   214  			if src == "-" {
   215  				data, err := io.ReadAll(opts.IO.In)
   216  				if err != nil {
   217  					return fmt.Errorf("read from stdin: %w", err)
   218  				}
   219  				text = string(data)
   220  			} else {
   221  				data, err := os.ReadFile(src)
   222  				if err != nil {
   223  					return fmt.Errorf("read %s: %w", src, err)
   224  				}
   225  				text = string(data)
   226  			}
   227  		} else {
   228  			editorCommand, err := cmdutil.DetermineEditor(opts.Config)
   229  			if err != nil {
   230  				return err
   231  			}
   232  
   233  			data, err := opts.Edit(editorCommand, filename, gistFile.Content, opts.IO)
   234  			if err != nil {
   235  				return err
   236  			}
   237  			text = data
   238  		}
   239  
   240  		if text != gistFile.Content {
   241  			gistFile.Content = text // so it appears if they re-edit
   242  			filesToUpdate[filename] = text
   243  		}
   244  
   245  		if !opts.IO.CanPrompt() ||
   246  			len(candidates) == 1 ||
   247  			opts.EditFilename != "" {
   248  			break
   249  		}
   250  
   251  		choice := ""
   252  
   253  		//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   254  		err = prompt.SurveyAskOne(&survey.Select{
   255  			Message: "What next?",
   256  			Options: []string{
   257  				"Edit another file",
   258  				"Submit",
   259  				"Cancel",
   260  			},
   261  		}, &choice)
   262  
   263  		if err != nil {
   264  			return fmt.Errorf("could not prompt: %w", err)
   265  		}
   266  
   267  		stop := false
   268  
   269  		switch choice {
   270  		case "Edit another file":
   271  			continue
   272  		case "Submit":
   273  			stop = true
   274  		case "Cancel":
   275  			return cmdutil.CancelError
   276  		}
   277  
   278  		if stop {
   279  			break
   280  		}
   281  	}
   282  
   283  	if len(filesToUpdate) > 0 {
   284  		shouldUpdate = true
   285  	}
   286  
   287  	if !shouldUpdate {
   288  		return nil
   289  	}
   290  
   291  	return updateGist(apiClient, host, gist)
   292  }
   293  
   294  func updateGist(apiClient *api.Client, hostname string, gist *shared.Gist) error {
   295  	body := shared.Gist{
   296  		Description: gist.Description,
   297  		Files:       gist.Files,
   298  	}
   299  
   300  	path := "gists/" + gist.ID
   301  
   302  	requestByte, err := json.Marshal(body)
   303  	if err != nil {
   304  		return err
   305  	}
   306  
   307  	requestBody := bytes.NewReader(requestByte)
   308  
   309  	result := shared.Gist{}
   310  
   311  	err = apiClient.REST(hostname, "POST", path, requestBody, &result)
   312  
   313  	if err != nil {
   314  		return err
   315  	}
   316  
   317  	return nil
   318  }
   319  
   320  func getFilesToAdd(file string, content []byte) (map[string]*shared.GistFile, error) {
   321  	if shared.IsBinaryContents(content) {
   322  		return nil, fmt.Errorf("failed to upload %s: binary file not supported", file)
   323  	}
   324  
   325  	if len(content) == 0 {
   326  		return nil, errors.New("file contents cannot be empty")
   327  	}
   328  
   329  	filename := filepath.Base(file)
   330  	return map[string]*shared.GistFile{
   331  		filename: {
   332  			Filename: filename,
   333  			Content:  string(content),
   334  		},
   335  	}, nil
   336  }