diff --git a/README.md b/README.md index 8a882f4..3e083b7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,24 @@ [![Build Status](https://travis-ci.com/isacikgoz/tldr.svg?branch=master)](https://travis-ci.com/isacikgoz/tldr) [![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](/LICENSE) # tldr++ -community driven man pages improved +community driven man pages improved with smart user interaction. tldr++ seperates itself with convenient user guidance and accelerates command generating. ![screenplay](img/screenplay.gif) +## Features +- Interactive (see the screencast) +- Smart file suggestions (further suggestions will be added) +- Simple implementation +- One of the fastest clients can be benchmarked with static option +- Multi-Platform +- *Pure-go (even git itself)* + +## Installation +Refer to [Release Page](https://github.com/isacikgoz/tldr/releases) + +## Credits +- [tldr-pages](https://github.com/tldr-pages/tldr) +- [survey](https://github.com/AlecAivazis/survey) +- [go-prompt](https://github.com/c-bata/go-prompt) +- [go-git](https://github.com/src-d/go-git) +- [kingpin](https://github.com/alecthomas/kingpin) \ No newline at end of file diff --git a/img/screenplay.gif.REMOVED.git-id b/img/screenplay.gif.REMOVED.git-id index b91d653..31b5a53 100644 --- a/img/screenplay.gif.REMOVED.git-id +++ b/img/screenplay.gif.REMOVED.git-id @@ -1 +1 @@ -a8fa8f110c0416fe7eeea6c12687abfaf6cd8469 \ No newline at end of file +0d942db9be4b6c5ef781645c1231159385cc805a \ No newline at end of file diff --git a/main.go b/main.go index 20f1dfd..9e9402b 100644 --- a/main.go +++ b/main.go @@ -10,33 +10,20 @@ import ( ) var ( - clear = kingpin.Flag("clear-cache", "clear local repository then clone github.com/tldr-pages/tldr").Short('c').Bool() - update = kingpin.Flag("update", "pulls the latest commits from github.com/tldr-pages/tldr").Short('u').Bool() - interactive = kingpin.Flag("interactive", "interactive mode.").Short('i').Default("true").Bool() + clear = kingpin.Flag("clear-cache", "clear local repository then clone github.com/tldr-pages/tldr").Short('c').Bool() + update = kingpin.Flag("update", "pulls the latest commits from github.com/tldr-pages/tldr").Short('u').Bool() + static = kingpin.Flag("static", "static mode.").Short('s').Default("false").Bool() - page = kingpin.Arg("command", "Name of the command.").String() + page = kingpin.Arg("command", "Name of the command.").Strings() ) func main() { - // start := time.Now() - kingpin.Version("tldr++ version 0.0.1 (pre-release)") - // parse the command line flag and options + + kingpin.Version("tldr++ version 0.1 (pre-release)") kingpin.Parse() - config.StartUp() - if *clear { - err := config.Clear() - if err != nil { - } - return - } - if *update { - err := config.PullSource() - if err != nil { + config.StartUp(*clear, *update) - } - return - } if len(*page) == 0 { kingpin.Usage() return @@ -48,23 +35,30 @@ func main() { } prompter := prompt.New(p) - - if err = prompter.RenderPage(); err != nil { - fmt.Printf("%s\n", err.Error()) + if err = prompter.RenderPage(*static); err != nil { + fmt.Printf("%s", err.Error()) + return } t, err := prompter.Selection() if err != nil { - fmt.Printf("%s\n", err.Error()) + fmt.Printf("%s", err.Error()) + return + } + + if t == nil { + return } cmd, err := prompter.GenerateCommand(t) if err != nil { fmt.Printf("%s\n", err.Error()) + return } if err = prompter.Run(cmd); err != nil { fmt.Printf("%s\n", err.Error()) + return } } diff --git a/pkg/config/configurer.go b/pkg/config/configurer.go index 3d72f9c..9c4cc2f 100644 --- a/pkg/config/configurer.go +++ b/pkg/config/configurer.go @@ -1,16 +1,31 @@ package config import ( + "fmt" "os" "runtime" - - log "github.com/sirupsen/logrus" ) -func StartUp() error { +// StartUp +func StartUp(clear, update bool) error { ok, _ := exists(SourceDir) - if !ok { - return Clear() + if !ok || staled() { + if err := Clear(); err != nil { + return err + } + } + if clear { + err := Clear() + if err != nil { + + } + os.Exit(0) + } else if update { + err := PullSource() + if err != nil { + + } + os.Exit(0) } return nil } @@ -27,11 +42,13 @@ func OSName() (n string) { case "solaris": n = "sunos" default: - log.Warn("Operating system couldn't be recognized") + fmt.Println("Operating system couldn't be recognized") + os.Exit(1) } return n } +// exists checks if the file exists func exists(path string) (bool, error) { _, err := os.Stat(path) if err == nil { diff --git a/pkg/config/source.go b/pkg/config/source.go index 4ae5363..0326c96 100644 --- a/pkg/config/source.go +++ b/pkg/config/source.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "time" "gopkg.in/src-d/go-git.v4" ) @@ -71,3 +72,15 @@ func DataDir() (d string) { } return d } + +func staled() bool { + file, _ := os.Open(SourceDir) + fstat, _ := file.Stat() + // now := time.Now() + + diff := time.Now().Sub(fstat.ModTime()) + if diff > 24*7*2*time.Hour { + return true + } + return false +} diff --git a/pkg/pages/command.go b/pkg/pages/command.go index a71cd1a..2386d7f 100644 --- a/pkg/pages/command.go +++ b/pkg/pages/command.go @@ -4,6 +4,7 @@ import ( "strings" ) +// Command is the representation of a tip's command suggestion type Command struct { Command string Args []string @@ -14,6 +15,7 @@ func (c *Command) String() string { return s } +// Display returns colored and indented text for rendering output func (c *Command) Display() string { s := c.Command for _, arg := range c.Args { @@ -21,5 +23,5 @@ func (c *Command) Display() string { t = t[:len(t)-2] s = strings.Replace(s, arg, cyan.Sprint(t), 1) } - return " " + s + "\n" + return " " + s + "\n" } diff --git a/pkg/pages/page.go b/pkg/pages/page.go index b264e2f..7e4b329 100644 --- a/pkg/pages/page.go +++ b/pkg/pages/page.go @@ -6,15 +6,19 @@ import ( ) var ( + // used for matching to args in a command rarg = regexp.MustCompile(`{{.[^}}]+}}`) ) +// Page is the representation of a tldr page itself type Page struct { Name string Desc string Tips []*Tip } +// Parse page from bare markdown string. Rather than parsing markdown itself +// initial implementation approach is stripping from a single string func ParsePage(s string) *Page { l := strings.Split(s, "\n") @@ -69,7 +73,8 @@ func (p *Page) String() string { return s } +// Display returns colored and indented text for rendering output func (p *Page) Display() string { - s := bold.Sprint(p.Name) + "\n\n" + p.Desc + "\n" + s := bold.Sprint(p.Name) + "\n\n" + p.Desc return s } diff --git a/pkg/pages/pages.go b/pkg/pages/pages.go index 78d5024..96ecc81 100644 --- a/pkg/pages/pages.go +++ b/pkg/pages/pages.go @@ -11,6 +11,7 @@ import ( var ( sep = string(os.PathSeparator) + // a page should have md file extension ext = ".md" bold = color.New(color.Bold) @@ -20,18 +21,31 @@ var ( white = color.New(color.FgWhite) ) -func Read(page string) (p *Page, err error) { +// Read finds and creates the Page, if it does not find, simply returns abstract +// contribution guide +func Read(seq []string) (p *Page, err error) { + page := "" + for i, l := range seq { + if len(seq)-1 == i { + page = page + l + break + } else { + page = page + l + "-" + } + } + // Common pages are more, so we have better luck there p, err = queryCommon(page) if err != nil { p, err = queryOS(page) if err != nil { - return p, errors.New("This page doesn't exist yet!\n" + + return p, errors.New("This page (" + page + ") doesn't exist yet!\n" + "Submit new pages here: https://github.com/tldr-pages/tldr") } } return p, nil } +// Queries from common folder func queryCommon(page string) (p *Page, err error) { d := config.SourceDir + sep + "pages" + sep + "common" + sep b, err := ioutil.ReadFile(d + page + ".md") @@ -42,6 +56,7 @@ func queryCommon(page string) (p *Page, err error) { return p, nil } +// Queries from os specific folder func queryOS(page string) (p *Page, err error) { d := config.SourceDir + sep + "pages" + sep + config.OSName() + sep b, err := ioutil.ReadFile(d + page + ".md") diff --git a/pkg/pages/tip.go b/pkg/pages/tip.go index 78ab0a3..ddd61f8 100644 --- a/pkg/pages/tip.go +++ b/pkg/pages/tip.go @@ -2,6 +2,7 @@ package pages import () +// Tip is the list item of a tldr page type Tip struct { Desc string Cmd *Command @@ -12,7 +13,8 @@ func (t *Tip) String() string { return s } +// Display returns colored and indented text for rendering output func (t *Tip) Display() string { - s := "- " + blue.Sprint(t.Desc) + "\n" + t.Cmd.Display() + s := " " + blue.Sprint(t.Desc) + "\n" + t.Cmd.Display() return s } diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index 979f2d2..993e588 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -1,6 +1,8 @@ package prompt import ( + "errors" + "fmt" "os" "os/exec" "strings" @@ -10,19 +12,24 @@ import ( "github.com/isacikgoz/tldr/pkg/pages" "gopkg.in/AlecAivazis/survey.v1" "gopkg.in/AlecAivazis/survey.v1/core" + "gopkg.in/AlecAivazis/survey.v1/terminal" ) +// Prompt struct is responsible for maintaining the life cycle of a tldr man page type Prompt struct { Page *pages.Page Questions []*survey.Question } +// New creates a new *prompt.Prompt obj. from a tldr Page func New(p *pages.Page) *Prompt { return &Prompt{ Page: p, } } -func (p *Prompt) RenderPage() error { + +// RenderPage prints the tldr man page as is +func (p *Prompt) RenderPage(static bool) error { options := make([]string, 0) @@ -30,32 +37,61 @@ func (p *Prompt) RenderPage() error { options = append(options, t.Display()) } core.ErrorTemplate = "" - // the questions to ask + // genereate questions to ask p.Questions = []*survey.Question{ { Name: "Tip", Prompt: &survey.Select{ - Message: p.Page.Display(), + Message: p.Page.Display() + "\n", Options: options, + VimMode: true, }, Validate: survey.Required, }, } + if static { + fmt.Println("\n" + p.Page.Display()) + for _, t := range p.Page.Tips { + fmt.Println("-" + t.Display()) + } + return errors.New("") + } + survey.SelectQuestionTemplate = ` +{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} +{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}} +{{- else}} + {{- " "}}(Use{{" "}}{{- color "cyan"}}arrows{{- color "reset"}}` + + ` to move,{{" "}}{{- color "cyan"}}type{{- color "reset"}} to filter or{{" "}}` + + `{{- color "red"}}ctrl+c{{- color "reset"}} to return{{- if and .Help (not .ShowHelp)}}` + + `, {{ HelpInputRune }} for more help{{end}}) + {{- "\n\n"}} + {{- range $ix, $choice := .PageEntries}} + {{- if eq $ix $.SelectedIndex}}{{color "blue+b"}}{{ "-" }} {{else}}{{color "default"}} {{end}} + {{- $choice}} + {{- color "reset"}}{{"\n"}} + {{- end}} +{{- end}}` + terminal.InterruptErr = errors.New("\x0d") return nil } +// Selection is where user interaction starts, hence we can *pages.Tip to iterate +// user interaction func (p *Prompt) Selection() (t *pages.Tip, err error) { answer := struct { Tip string }{} - + // bug: https://github.com/AlecAivazis/survey/issues/101 + // make terminal not line wrap + fmt.Printf("\x1b[?7l") + // defer restoring line wrap + defer fmt.Printf("\x1b[?7h") // ask the question err = survey.Ask(p.Questions, &answer) if err != nil { return nil, err } - var st *pages.Tip for _, t := range p.Page.Tips { if t.Display() == answer.Tip { @@ -65,19 +101,28 @@ func (p *Prompt) Selection() (t *pages.Tip, err error) { return st, err } +// GenerateCommand generates command form *pages.Tip func (p *Prompt) GenerateCommand(t *pages.Tip) (string, error) { - // fmt.Print(st.Cmd.Display()) + answers := make([]string, 0) for _, arg := range t.Cmd.Args { - cs, _ := suggestCompleterFunc(arg) + // cs, _ := suggestCompleterFunc(arg) + ext = getFileExtension(arg) answers = append(answers, prompt.Input( "$"+" "+arg[:len(arg)-2][2:]+" -> ", - cs.Complete, - prompt.OptionPrefixTextColor(prompt.Cyan), - prompt.OptionSuggestionBGColor(prompt.DarkGray), + fileExtCompleterFunc, + prompt.OptionPreviewSuggestionTextColor(prompt.Cyan), + prompt.OptionInputTextColor(prompt.Cyan), + prompt.OptionAddKeyBind(prompt.KeyBind{ + Key: prompt.ControlC, + Fn: func(buf *prompt.Buffer) { + os.Exit(0) + return + }}), prompt.OptionAddKeyBind(prompt.KeyBind{ Key: prompt.Escape, Fn: func(buf *prompt.Buffer) { + return }}), prompt.OptionCompletionWordSeparator(completer.FilePathCompletionSeparator), @@ -91,6 +136,7 @@ func (p *Prompt) GenerateCommand(t *pages.Tip) (string, error) { return fs, nil } +// Run gets final confirmation from user and executes the command with its args func (p *Prompt) Run(command string) error { run := false confirm := &survey.Confirm{ diff --git a/pkg/prompt/suggest.go b/pkg/prompt/suggest.go index 0dcc23d..6adce0d 100644 --- a/pkg/prompt/suggest.go +++ b/pkg/prompt/suggest.go @@ -2,7 +2,6 @@ package prompt import ( "errors" - "io/ioutil" "os" "strings" @@ -12,28 +11,34 @@ import ( var ( pathTo = "path/to/" - ext = ".ext" - - filePathCompleter = completer.FilePathCompleter{ - IgnoreCase: true, - Filter: func(fi os.FileInfo) bool { - return fi.IsDir() || strings.HasSuffix(fi.Name(), ".go") - }, - } + ext = ".*" ) -func completerLegacyFunc(t prompt.Document) []prompt.Suggest { - files, _ := ioutil.ReadDir("./") +// if the arg extension is matched, suggested values moves top of the slice +// implementation could be beter +func fileExtCompleterFunc(t prompt.Document) []prompt.Suggest { s := make([]prompt.Suggest, 0) - for _, f := range files { - s = append(s, prompt.Suggest{ - Text: f.Name(), - }) + if len(ext) > 0 { + filePathExtCompleter := completer.FilePathCompleter{ + IgnoreCase: true, + Filter: func(fi os.FileInfo) bool { + promoted := strings.HasSuffix(fi.Name(), ext) + return promoted + }, + } + s = filePathExtCompleter.Complete(t) } - return s + f := filePathCompleterFunc(t) + s = append(s, f...) + + return removeDuplicates(s) } +// default file path completer, return all files func filePathCompleterFunc(d prompt.Document) []prompt.Suggest { + filePathCompleter := completer.FilePathCompleter{ + IgnoreCase: true, + } return filePathCompleter.Complete(d) } @@ -45,11 +50,13 @@ func suggestCompleterFunc(arg string) (completer.FilePathCompleter, error) { return filePathCompleter, nil } else { ext := getFileExtension(arg) + // the arg should be longer than regular extension length such as "a.z" if len(arg) > 3 && len(ext) > 0 { filePathCompleter := completer.FilePathCompleter{ IgnoreCase: true, Filter: func(fi os.FileInfo) bool { - return strings.HasSuffix(fi.Name(), ext) + promoted := strings.HasSuffix(fi.Name(), ext) + return promoted }, } return filePathCompleter, nil @@ -60,7 +67,9 @@ func suggestCompleterFunc(arg string) (completer.FilePathCompleter, error) { } } +// returns the file extension of the argument func getFileExtension(arg string) string { + // probably not a file. hence, wont have an extension if strings.Contains(arg, "..") { return "" } @@ -72,8 +81,29 @@ func getFileExtension(arg string) string { break } } + // we expect a dot to determine if it is an extension if strings.Contains(ext, ".") { return ext } return "" } + +// removes duplicate entries from prompt.Suggest slice +func removeDuplicates(elements []prompt.Suggest) []prompt.Suggest { + // Use map to record duplicates as we find them. + encountered := map[prompt.Suggest]bool{} + result := []prompt.Suggest{} + + for v := range elements { + if encountered[elements[v]] == true { + // Do not add duplicate. + } else { + // Record this element as an encountered element. + encountered[elements[v]] = true + // Append to result slice. + result = append(result, elements[v]) + } + } + // Return the new slice. + return result +}