From de67a9a56e9f6b872318f564a30f16d2dd0065fc Mon Sep 17 00:00:00 2001 From: b4b4r07 Date: Wed, 19 Apr 2017 12:39:47 +0900 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 20 ++ Makefile | 22 ++ README.md | 92 ++++++++ cmd/config.go | 30 +++ cmd/delete.go | 62 ++++++ cmd/edit.go | 65 ++++++ cmd/new.go | 143 ++++++++++++ cmd/open.go | 51 +++++ cmd/root.go | 54 +++++ config/config.go | 111 ++++++++++ gist/gist.go | 454 ++++++++++++++++++++++++++++++++++++++ main.go | 7 + misc/completion/zsh/_gist | 55 +++++ util/util.go | 167 ++++++++++++++ 15 files changed, 1335 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/config.go create mode 100644 cmd/delete.go create mode 100644 cmd/edit.go create mode 100644 cmd/new.go create mode 100644 cmd/open.go create mode 100644 cmd/root.go create mode 100644 config/config.go create mode 100644 gist/gist.go create mode 100644 main.go create mode 100644 misc/completion/zsh/_gist create mode 100644 util/util.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4df7939 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +gm +bin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5677770 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright © 2017 Masaki Ishiyama + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..63f0cf0 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +TEST?=./... +DEPS = $(shell go list -f '{{range .TestImports}}{{.}} {{end}}' ./...) + +all: build + +build: deps + mkdir -p bin + go build -o bin/gist + +install: build + install -m 755 ./bin/gist ~/bin/gist + +deps: + go get -d -v ./... + echo $(DEPS) | xargs -n1 go get -d + +test: deps + go test $(TEST) $(TESTARGS) -timeout=3s -parallel=4 + go vet $(TEST) + go test $(TEST) -race + +.PHONY: all build deps test diff --git a/README.md b/README.md new file mode 100644 index 0000000..b99edcb --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ + + +> A simple gist editor for CLI +> +> + +## Pros + +- Simple and intuitive + - Just select and edit gist you want + - Can use any editors what you want + - Work with [peco](https://github.com/peco/peco) and [fzf](https://github.com/junegunn/fzf) + - Automatically synchronized after editing +- Customizable + - A few options and small TOML +- Easy to install + - Go! single binary + +***DEMO*** + + + +## Usage + +Currently gist supports the following commands: + +```console +$ gist help +gist - A simple gist editor for CLI + +Usage: + gist [flags] + gist [command] + +Available Commands: + config Config the setting file + delete Delete gist files + edit Edit the gist file and sync after + new Create a new gist + open Open user's gist + +Flags: + -v, --version show the version and exit + +Use "gist [command] --help" for more information about a command. +``` + +### Configurations + +Well-specified options and user-specific settings can be described in a toml file. It can be changed with the `gist set` command. + +```toml +[Core] + Editor = "vim" + selectcmd = "fzf-tmux --multi:fzf:peco:percol" + tomlfile = "/Users/b4b4r07/.config/gist/config.toml" + user = "b4b4r07" + +[Gist] + token = "your_github_token" + dir = "/Users/b4b4r07/.config/gist/files" + +[Flag] + open_url = false + private = false + verbose = true + show_spinner = true +``` + +This behavior was heavily inspired by [mattn/memo](https://github.com/mattn/memo), thanks! + +## Installation + +```console +$ go get github.com/b4b4r07/gist +``` + +## Versus + +There are many other implements as the gist client (called "gister") such as the following that works on command-line: + +- +- +- ... + +## License + +MIT + +## Author + +b4b4r07 diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..fa2eddd --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "path/filepath" + + "github.com/b4b4r07/gist/config" + "github.com/b4b4r07/gist/util" + "github.com/spf13/cobra" +) + +var confCmd = &cobra.Command{ + Use: "config", + Short: "Config the setting file", + Long: "Config the setting file with your editor (default: vim)", + RunE: conf, +} + +func conf(cmd *cobra.Command, args []string) error { + editor := config.Conf.Core.Editor + tomlfile := config.Conf.Core.TomlFile + if tomlfile == "" { + dir, _ := config.GetDefaultDir() + tomlfile = filepath.Join(dir, "config.toml") + } + return util.RunCommand(editor, tomlfile) +} + +func init() { + RootCmd.AddCommand(confCmd) +} diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..7342ed9 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "log" + + "github.com/b4b4r07/gist/config" + "github.com/b4b4r07/gist/gist" + "github.com/b4b4r07/gist/util" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete gist files", + Long: "Delete gist files on the remote", + RunE: delete, +} + +func delete(cmd *cobra.Command, args []string) error { + var err error + + gist, err := gist.New(config.Conf.Gist.Token) + if err != nil { + return err + } + + gfs, err := gist.GetRemoteFiles() + if err != nil { + return err + } + + selectedLines, err := util.Filter(gfs.Text) + if err != nil { + return err + } + + var ids []string + for _, line := range selectedLines { + if line == "" { + continue + } + parsedLine, err := util.ParseLine(line) + if err != nil { + continue + } + ids = append(ids, gfs.ExtendID(parsedLine.ID)) + } + + ids = util.UniqueArray(ids) + for _, id := range ids { + err = gist.Delete(id) + if err != nil { + log.Printf("[ERROR] %v", err) + } + } + + return nil +} + +func init() { + RootCmd.AddCommand(deleteCmd) +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..b2780a0 --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "path/filepath" + + "github.com/b4b4r07/gist/config" + "github.com/b4b4r07/gist/gist" + "github.com/b4b4r07/gist/util" + "github.com/spf13/cobra" +) + +var editCmd = &cobra.Command{ + Use: "edit", + Short: "Edit the gist file and sync after", + Long: "Edit the gist file and sync after", + RunE: edit, +} + +func edit(cmd *cobra.Command, args []string) error { + var err error + + gist, err := gist.New(config.Conf.Gist.Token) + if err != nil { + return err + } + + gfs, err := gist.GetRemoteFiles() + if err != nil { + return err + } + + selectedLines, err := util.Filter(gfs.Text) + if err != nil { + return err + } + + for _, line := range selectedLines { + if line == "" { + continue + } + var url string + parsedLine, err := util.ParseLine(line) + if err != nil { + continue + } + + file := filepath.Join(config.Conf.Gist.Dir, gfs.ExtendID(parsedLine.ID), parsedLine.Filename) + err = gist.Edit(file) + if err != nil { + return err + } + + // TODO: FIXME: gist.Edit + if config.Conf.Flag.OpenURL { + util.Open(url) + } + } + + return nil +} + +func init() { + RootCmd.AddCommand(editCmd) + editCmd.Flags().BoolVarP(&config.Conf.Flag.OpenURL, "open", "o", false, "Open with the default browser") +} diff --git a/cmd/new.go b/cmd/new.go new file mode 100644 index 0000000..cde6ffb --- /dev/null +++ b/cmd/new.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/b4b4r07/gist/config" + "github.com/b4b4r07/gist/gist" + "github.com/b4b4r07/gist/util" + "github.com/chzyer/readline" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var newCmd = &cobra.Command{ + Use: "new [FILE/DIR]", + Short: "Create a new gist", + Long: `Create a new gist. If you pass file/dir paths, upload those files`, + RunE: new, +} + +func scan(message string) (string, error) { + tmp := "/tmp" + if runtime.GOOS == "windows" { + tmp = os.Getenv("TEMP") + } + l, err := readline.NewEx(&readline.Config{ + Prompt: message, + HistoryFile: filepath.Join(tmp, "gist.txt"), + InterruptPrompt: "^C", + EOFPrompt: "exit", + HistorySearchFold: true, + }) + if err != nil { + return "", err + } + defer l.Close() + + for { + line, err := l.Readline() + if err == readline.ErrInterrupt { + if len(line) == 0 { + break + } else { + continue + } + } else if err == io.EOF { + break + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + return line, nil + } + return "", errors.New("canceled") +} + +func new(cmd *cobra.Command, args []string) error { + var fname string + var desc string + var err error + + gist_, err := gist.New(config.Conf.Gist.Token) + if err != nil { + return err + } + + var gistFiles []gist.File + + // TODO: refactoring + if len(args) > 0 { + target := args[0] + files := []string{} + err = filepath.Walk(target, func(path string, info os.FileInfo, err error) error { + if strings.HasPrefix(path, ".") { + return nil + } + if info.IsDir() { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + return err + } + if len(files) == 0 { + return fmt.Errorf("%s: no files", target) + } + for _, file := range files { + fmt.Fprintf(color.Output, "%s %s\n", color.YellowString("Filename>"), file) + gistFiles = append(gistFiles, gist.File{ + Filename: filepath.Base(file), + Content: util.FileContent(file), + }) + } + } else { + filename, err := scan(color.YellowString("Filename> ")) + if err != nil { + return err + } + f, err := util.TempFile(filename) + defer os.Remove(f.Name()) + fname = f.Name() + err = util.RunCommand(config.Conf.Core.Editor, fname) + if err != nil { + return err + } + gistFiles = append(gistFiles, gist.File{ + Filename: filename, + Content: util.FileContent(fname), + }) + } + + desc, err = scan(color.GreenString("Description> ")) + if err != nil { + return err + } + + url, err := gist_.Create(gistFiles, desc) + if err != nil { + return err + } + util.Underline("Created", url) + + if config.Conf.Flag.OpenURL { + util.Open(url) + } + return nil +} + +func init() { + RootCmd.AddCommand(newCmd) + newCmd.Flags().BoolVarP(&config.Conf.Flag.OpenURL, "open", "o", false, "Open with the default browser") + newCmd.Flags().BoolVarP(&config.Conf.Flag.Private, "private", "p", false, "Create as private gist") +} diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..b7180b3 --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "net/url" + "path" + + "github.com/b4b4r07/gist/config" + "github.com/b4b4r07/gist/util" + "github.com/spf13/cobra" +) + +var openCmd = &cobra.Command{ + Use: "open", + Short: "Open user's gist", + Long: "Open user's gist", + RunE: open, +} + +func open(cmd *cobra.Command, args []string) error { + gistURL := config.Conf.Core.BaseURL + + u, err := url.Parse(gistURL) + if err != nil { + return err + } + + q := u.Query() + + user := config.Conf.Core.User + if user != "" { + u.Path = path.Join(u.Path, user) + } + + if config.Conf.Flag.Sort == "updated" { + q.Set("direction", "desc") + q.Set("sort", "updated") + } + if config.Conf.Flag.Only == "secret" || config.Conf.Flag.Only == "private" { + u.Path = path.Join(u.Path, "secret") + } + + u.RawQuery = q.Encode() + + return util.Open(u.String()) +} + +func init() { + RootCmd.AddCommand(openCmd) + openCmd.Flags().StringVarP(&config.Conf.Flag.Sort, "sort", "", "created", "Sort") + openCmd.Flags().StringVarP(&config.Conf.Flag.Only, "only", "", "", "Only") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..4d8b162 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/b4b4r07/gist/config" + "github.com/spf13/cobra" +) + +const Version = "0.1.2" + +var showVersion bool + +var RootCmd = &cobra.Command{ + Use: "gist", + Short: "gist editor", + Long: "gist - A simple gist editor for CLI", + SilenceUsage: true, + SilenceErrors: true, + Run: func(cmd *cobra.Command, args []string) { + if showVersion { + fmt.Printf("version %s/%s\n", Version, runtime.Version()) + return + } + cmd.Usage() + }, +} + +func Execute() { + err := RootCmd.Execute() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConf) + RootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "show the version and exit") +} + +func initConf() { + dir, _ := config.GetDefaultDir() + toml := filepath.Join(dir, "config.toml") + + err := config.Conf.LoadFile(toml) + if err != nil { + fmt.Fprintln(os.Stderr, "Error: %v", err) + os.Exit(1) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3754d30 --- /dev/null +++ b/config/config.go @@ -0,0 +1,111 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Core Core + Gist Gist + Flag Flag +} + +type Core struct { + Editor string `toml:"editor"` + SelectCmd string `toml:"selectcmd"` + TomlFile string `toml:"tomlfile"` + User string `toml:"user"` + ShowIndicator bool `toml:"show_indicator"` + BaseURL string `toml:"base_url"` +} + +type Gist struct { + Token string `toml:"token"` + Dir string `toml:"dir"` +} + +type Flag struct { + OpenURL bool `toml:"open_url"` + Private bool `toml:"private"` + Verbose bool `toml:"verbose"` + ShowSpinner bool `toml:"show_spinner"` + Sort string `toml:"sort"` + Only string `toml:"only"` +} + +var Conf Config + +func expandPath(s string) string { + if len(s) >= 2 && s[0] == '~' && os.IsPathSeparator(s[1]) { + if runtime.GOOS == "windows" { + s = filepath.Join(os.Getenv("USERPROFILE"), s[2:]) + } else { + s = filepath.Join(os.Getenv("HOME"), s[2:]) + } + } + return os.Expand(s, os.Getenv) +} + +func GetDefaultDir() (string, error) { + var dir string + + switch runtime.GOOS { + default: + dir = filepath.Join(os.Getenv("HOME"), ".config") + case "windows": + dir = os.Getenv("APPDATA") + if dir == "" { + dir = filepath.Join(os.Getenv("USERPROFILE"), "Application Data") + } + } + dir = filepath.Join(dir, "gist") + + err := os.MkdirAll(dir, 0700) + if err != nil { + return dir, fmt.Errorf("cannot create directory: %v", err) + } + + return dir, nil +} + +func (cfg *Config) LoadFile(file string) error { + _, err := os.Stat(file) + if err == nil { + _, err := toml.DecodeFile(file, cfg) + if err != nil { + return err + } + return nil + } + + if !os.IsNotExist(err) { + return err + } + f, err := os.Create(file) + if err != nil { + return err + } + + cfg.Gist.Token = os.Getenv("GITHUB_TOKEN") + cfg.Core.Editor = os.Getenv("EDITOR") + if cfg.Core.Editor == "" { + cfg.Core.Editor = "vim" + } + cfg.Core.SelectCmd = "peco" + cfg.Core.TomlFile = file + cfg.Core.User = os.Getenv("USER") + cfg.Core.ShowIndicator = true + cfg.Core.BaseURL = "https://gist.github.com" + dir := filepath.Join(filepath.Dir(file), "files") + os.MkdirAll(dir, 0700) + cfg.Gist.Dir = dir + cfg.Flag.ShowSpinner = true + cfg.Flag.Verbose = true + + return toml.NewEncoder(f).Encode(cfg) +} diff --git a/gist/gist.go b/gist/gist.go new file mode 100644 index 0000000..7076e45 --- /dev/null +++ b/gist/gist.go @@ -0,0 +1,454 @@ +package gist + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/b4b4r07/gist/config" + "github.com/b4b4r07/gist/util" + "github.com/briandowns/spinner" + "github.com/google/go-github/github" + "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +var ( + SpinnerSymbol int = 14 + ShowSpinner bool = true + Verbose bool = true + + DescriptionEmpty string = "description" +) + +type ( + Items []*github.Gist + Item *github.Gist +) + +type Gist struct { + Client *github.Client + Items Items +} + +type File struct { + ID string + ShortID string + Filename string + Path string + FullPath string + Content string + Description string +} + +type Files []File + +type GistFiles struct { + Files []File + Text string +} + +func New(token string) (*Gist, error) { + if token == "" { + return &Gist{}, errors.New("token is missing") + } + + // TODO: c.f. go-redis + ShowSpinner = config.Conf.Flag.ShowSpinner + Verbose = config.Conf.Flag.Verbose + + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(oauth2.NoContext, ts) + client := github.NewClient(tc) + + return &Gist{ + Client: client, + Items: []*github.Gist{}, + }, nil +} + +// TODO +// replacement with pkg/errors +func errorWrapper(err error) error { + if strings.Contains(err.Error(), "tcp") { + return errors.New("Try again when you have a better network connection") + } + return err +} + +func (g *Gist) getItems() error { + var items Items + + // Get items from gist.github.com + gists, resp, err := g.Client.Gists.List("", &github.GistListOptions{}) + if err != nil { + return errorWrapper(err) + // return err + } + items = append(items, gists...) + + // pagenation + for i := 2; i <= resp.LastPage; i++ { + gists, _, err := g.Client.Gists.List("", &github.GistListOptions{ + ListOptions: github.ListOptions{Page: i}, + }) + if err != nil { + continue + } + items = append(items, gists...) + } + g.Items = items + + if len(g.Items) == 0 { + return errors.New("no items") + } + return nil +} + +func (g *Gist) GetRemoteFiles() (gfs GistFiles, err error) { + if ShowSpinner { + s := spinner.New(spinner.CharSets[SpinnerSymbol], 100*time.Millisecond) + s.Suffix = " Fetching..." + s.Start() + defer s.Stop() + } + + // fetch remote files + err = g.getItems() + if err != nil { + return gfs, err + } + + // for downloading + oldwd, _ := os.Getwd() + dir := config.Conf.Gist.Dir + if _, err := os.Stat(dir); os.IsNotExist(err) { + os.Mkdir(dir, 0700) + } + os.Chdir(dir) + defer os.Chdir(oldwd) + + var files Files + for _, item := range g.Items { + if !util.Exists(*item.ID) { + // TODO: Start() + err := exec.Command("git", "clone", *item.GitPullURL).Start() + if err != nil { + continue + } + } + desc := "" + if item.Description != nil { + desc = *item.Description + } + if desc == DescriptionEmpty { + desc = "" + } + for _, f := range item.Files { + files = append(files, File{ + ID: *item.ID, + ShortID: util.ShortenID(*item.ID), + Filename: *f.Filename, + Path: filepath.Join(*item.ID, *f.Filename), + FullPath: filepath.Join(config.Conf.Gist.Dir, *item.ID, *f.Filename), + Description: desc, + }) + } + } + + var text string + var length int + max := len(files) - 1 + prefixes := make([]string, max+1) + var previous, current, next string + for i, file := range files { + if len(file.Filename) > length { + length = len(file.Filename) + } + current = files[i].ID + switch { + case i == 0: + previous = "" + next = files[i+1].ID + case 0 < i && i < max: + previous = files[i-1].ID + next = files[i+1].ID + case i == max: + previous = files[i-1].ID + next = "" + } + prefixes[i] = " " + if current == previous { + prefixes[i] = "|" + if current != next { + prefixes[i] = "+" + } + } + if current == next { + prefixes[i] = "|" + if current != previous { + prefixes[i] = "+" + } + } + } + format := fmt.Sprintf("%%-%ds\t%%-%ds\t%%s\n", util.LengthID, length) + if config.Conf.Core.ShowIndicator { + format = fmt.Sprintf(" %%s %%-%ds\t%%-%ds\t%%s\n", util.LengthID, length) + } + for i, file := range files { + if config.Conf.Core.ShowIndicator { + text += fmt.Sprintf(format, prefixes[i], util.ShortenID(file.ID), file.Filename, file.Description) + } else { + text += fmt.Sprintf(format, util.ShortenID(file.ID), file.Filename, file.Description) + } + } + + return GistFiles{ + Files: files, + Text: text, + }, nil +} + +func (g *Gist) Create(files Files, desc string) (url string, err error) { + if ShowSpinner { + s := spinner.New(spinner.CharSets[SpinnerSymbol], 100*time.Millisecond) + s.Suffix = " Creating..." + s.Start() + defer s.Stop() + } + + public := true + if config.Conf.Flag.Private { + public = false + } + gistFiles := make(map[github.GistFilename]github.GistFile, len(files)) + for _, file := range files { + filename := file.Filename + content := file.Content + fname := github.GistFilename(filename) + gistFiles[fname] = github.GistFile{ + Filename: &filename, + Content: &content, + } + } + gistResp, resp, err := g.Client.Gists.Create(&github.Gist{ + Files: gistFiles, + Public: &public, + Description: &desc, + }) + if resp == nil { + return url, errors.New("Try again when you have a better network connection") + } + url = *gistResp.HTMLURL + return url, errors.Wrap(err, "Failed to create") +} + +func (g *Gist) Delete(id string) error { + if ShowSpinner { + s := spinner.New(spinner.CharSets[SpinnerSymbol], 100*time.Millisecond) + s.Suffix = " Deleting..." + s.Start() + defer func() { + s.Stop() + fmt.Printf("Deleted %s\n", id) + }() + } + _, err := g.Client.Gists.Delete(id) + return err +} + +func (f *Files) Filter(fn func(File) bool) *Files { + files := make(Files, 0) + for _, file := range *f { + if fn(file) { + files = append(files, file) + } + } + return &files +} + +func (i *Items) Filter(fn func(Item) bool) *Items { + items := make(Items, 0) + for _, item := range *i { + if fn(item) { + items = append(items, item) + } + } + return &items +} + +func (i *Items) One() Item { + var item Item + if len(*i) > 0 { + return (*i)[0] + } + return item +} + +// TODO: +// Gist -> []Files +func (g *Gist) Download(fname string) (url string, err error) { + if ShowSpinner { + s := spinner.New(spinner.CharSets[SpinnerSymbol], 100*time.Millisecond) + s.Suffix = " Checking..." + s.Start() + defer s.Stop() + } + + gists := g.Items.Filter(func(i Item) bool { + return *i.ID == util.GetID(fname) + }) + + for _, gist := range *gists { + g, _, err := g.Client.Gists.Get(*gist.ID) + if err != nil { + return url, err + } + // for multiple files in one Gist folder + for _, f := range g.Files { + fpath := filepath.Join(config.Conf.Gist.Dir, *gist.ID, *f.Filename) + content := util.FileContent(fpath) + // write to the local files if there are some diff + if *f.Content != content { + ioutil.WriteFile(fpath, []byte(*f.Content), os.ModePerm) + // After rewriting returns URL + url = *gist.HTMLURL + } + } + } + return url, nil +} + +func makeGist(fname string) github.Gist { + body := util.FileContent(fname) + return github.Gist{ + Description: github.String("description"), + Public: github.Bool(true), + Files: map[github.GistFilename]github.GistFile{ + github.GistFilename(filepath.Base(fname)): github.GistFile{ + Content: github.String(body), + }, + }, + } +} + +// TODO: +// Gist -> []Files +func (g *Gist) Upload(fname string) (url string, err error) { + if ShowSpinner { + s := spinner.New(spinner.CharSets[SpinnerSymbol], 100*time.Millisecond) + s.Suffix = " Checking..." + s.Start() + defer s.Stop() + } + + var ( + gistID = util.GetID(fname) + gist = makeGist(fname) + filename = filepath.Base(fname) + content = util.FileContent(fname) + ) + + res, _, err := g.Client.Gists.Get(gistID) + if err != nil { + return url, err + } + + name := github.GistFilename(filename) + if *res.Files[name].Content != content { + gistResp, _, err := g.Client.Gists.Edit(gistID, &gist) + if err != nil { + return url, err + } + url = *gistResp.HTMLURL + } + + return url, nil +} + +func (g *Gist) Sync(fname string) error { + var err error + + if len(g.Items) == 0 { + err = g.getItems() + if err != nil { + return err + } + } + + item := g.Items.Filter(func(i Item) bool { + return *i.ID == util.GetID(fname) + }).One() + + fi, err := os.Stat(fname) + if err != nil { + return err + } + + // TODO: utc + jst := time.FixedZone("Asia/Tokyo", 9*60*60) + local := fi.ModTime().In(jst) + remote := item.UpdatedAt.In(jst) + + var ( + msg, url string + ) + if local.After(remote) { + url, err = g.Upload(fname) + if err != nil { + return err + } + msg = "Uploaded" + } else if remote.After(local) { + url, err = g.Download(fname) + if err != nil { + return err + } + msg = "Downloaded" + } else { + return errors.New("something wrong") + } + if Verbose { + util.Underline(msg, url) + } + + return nil +} + +func (g *Gist) Edit(fname string) error { + var err error + // TODO: use pkg/errors + + err = g.Sync(fname) + if err != nil { + return err + } + + // err = config.Conf.Command(config.Conf.Core.Editor, "", fname) + err = util.RunCommand(config.Conf.Core.Editor, fname) + if err != nil { + return err + } + + err = g.Sync(fname) + if err != nil { + return err + } + + return nil +} + +func (gfs *GistFiles) ExtendID(id string) string { + for _, file := range gfs.Files { + if file.ShortID == id { + return file.ID + } + } + return "" +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6c66410 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/b4b4r07/gist/cmd" + +func main() { + cmd.Execute() +} diff --git a/misc/completion/zsh/_gist b/misc/completion/zsh/_gist new file mode 100644 index 0000000..801d6f2 --- /dev/null +++ b/misc/completion/zsh/_gist @@ -0,0 +1,55 @@ +#compdef gist +# URL: https://github.com/b4b4r07/gist +# vim: ft=zsh + +_gist () { + local -a _1st_arguments + _1st_arguments=( + 'open:Open user''s gist' + 'edit:Edit the gist file and sync after' + 'new:Create a new gist' + 'delete:Delete gist files' + 'config:Config the setting file' + 'help:Show help for any command' + ) + + _arguments \ + '(--help)--help[help message]' \ + '(-v --version)'{-v,--version}'[version]' \ + '*:: :->subcmds' \ + && return 0 + + if (( CURRENT == 1 )); then + _describe -t commands "gist subcommand" _1st_arguments + return + fi + + case "$words[1]" in + (open) + _arguments \ + '(--sort)--sort=[Sort by the argument]: :(created updated)' \ + '(--only)--only=[Open only for the condition]: :(secret public private)' \ + && return 0 + ;; + (edit) + _arguments \ + '(-o --open)'{-o,--open}'[Open with the default browser]' \ + && return 0 + ;; + (new) + _arguments \ + '(- :)'{-h,--help}'[Show this help and exit]' \ + '(-o --open)'{-o,--open}'[Open with the default browser]' \ + '(-p --private)'{-p,--private}'[Create as private gist]' \ + '(-)*: :_files' \ + && return 0 + ;; + (delete) ;; + (config) ;; + (help) + _values 'help message' ${_1st_arguments[@]%:*} && return 0 + ;; + esac +} + +_gist "$@" diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..dcd93a9 --- /dev/null +++ b/util/util.go @@ -0,0 +1,167 @@ +package util + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/b4b4r07/gist/config" + "github.com/b4b4r07/go-colon" + "github.com/fatih/color" + "github.com/skratchdot/open-golang/open" +) + +const LengthID = 9 + +func Open(target string) error { + _, err := url.ParseRequestURI(target) + if err != nil { + return err + } + return open.Start(target) +} + +func Underline(message, target string) { + if target == "" { + return + } + link := color.New(color.Underline).SprintFunc() + fmt.Printf("%s %s\n", message, link(target)) +} + +func GetID(file string) string { + return filepath.Base(filepath.Dir(file)) +} + +func FileContent(fname string) string { + data, err := ioutil.ReadFile(fname) + if err != nil { + panic(err) + } + return string(data) +} + +func Exists(filename string) bool { + _, err := os.Stat(filename) + return err == nil +} + +func TempFile(filename string) (*os.File, error) { + return os.Create(filepath.Join(os.TempDir(), filename)) +} + +func UniqueArray(args []string) []string { + ret := make([]string, 0, len(args)) + encountered := map[string]bool{} + for _, arg := range args { + if !encountered[arg] { + encountered[arg] = true + ret = append(ret, arg) + } + } + return ret +} + +func Filter(text string) ([]string, error) { + var ( + selectedLines []string + buf bytes.Buffer + err error + ) + if text == "" { + return selectedLines, errors.New("No input") + } + err = runFilter(config.Conf.Core.SelectCmd, strings.NewReader(text), &buf) + if err != nil { + return selectedLines, err + } + if buf.Len() == 0 { + return selectedLines, errors.New("no lines selected") + } + selectedLines = strings.Split(buf.String(), "\n") + return selectedLines, nil +} + +func runFilter(command string, r io.Reader, w io.Writer) error { + if command == "" { + return errors.New("Specify the selectcmd e.g. peco/fzf") + } + command = os.Expand(command, os.Getenv) + result, err := colon.Parse(command) + if err != nil { + return err + } + command = strings.Join(result.Executable().One().Attr.Args, " ") + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + cmd.Stderr = os.Stderr + cmd.Stdout = w + cmd.Stdin = r + return cmd.Run() +} + +func RunCommand(command string, args ...string) error { + if command == "" { + return errors.New("command not found") + } + command += " " + strings.Join(args, " ") + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + return cmd.Run() +} + +type ParsedLine struct { + ID, Filename, Description, Path string +} + +func ParseLine(line string) (*ParsedLine, error) { + l := strings.Split(line, "\t") + if len(l) != 3 { + return &ParsedLine{}, errors.New("error") + } + var ( + id = func(id string) string { + id = strings.TrimSpace(id) + id = strings.TrimLeft(id, " | ") + id = strings.TrimLeft(id, " + ") + return id + }(l[0]) + filename = strings.TrimSpace(l[1]) + description = l[2] + ) + return &ParsedLine{ + ID: id, + Filename: filename, + Description: description, + Path: filepath.Join(id, filename), + }, nil +} + +func ShortenID(id string) string { + var ret string + for pos, str := range strings.Split(id, "") { + if pos <= LengthID { + ret += str + } + } + return ret +}