From 4eb742ce4f9ab7bb7ef28dd89cdf14181a530e48 Mon Sep 17 00:00:00 2001 From: Marco Paganini Date: Fri, 16 Feb 2024 19:37:06 -0800 Subject: [PATCH] Add fzf support (--fzf). --- main.go | 224 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 169 insertions(+), 55 deletions(-) diff --git a/main.go b/main.go index 42bac49..09a0511 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,22 @@ // This file is part of termOTP, a TOTP program for your terminal. // https://github.com/marcopaganini/termotp. +// (C) 2024 by Marco Paganini package main import ( + "errors" "flag" "fmt" "os" + "os/exec" "path/filepath" "regexp" "sort" + "strings" "time" "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" "github.com/romana/rlog" ) @@ -26,7 +31,16 @@ type otpEntry struct { token string } -// die logs a message with rlog.Critical and exists with a return code. +// cmdLineFlags contains the command-line flags. +type cmdLineFlags struct { + input string + fuzzy bool + fzf bool + plain bool + version bool +} + +// die logs a message with rlog.Critical and exits with a return code. func die(v ...any) { if v != nil { rlog.Critical(v...) @@ -34,16 +48,6 @@ func die(v ...any) { os.Exit(1) } -// usage prints a usage message, defaults for flags, and exits with error. -func usage(s string) { - if s != "" { - fmt.Fprintf(os.Stderr, "Please specify input file with --input\n\n") - } - flag.Usage() - flag.PrintDefaults() - os.Exit(1) -} - // inputFile expands the glob passed as an argument and returns the file with // the most recent modification time in the list. func inputFile(fileglob string) (string, error) { @@ -72,38 +76,159 @@ func inputFile(fileglob string) (string, error) { return nfile, nil } -func main() { - // Usage prints the default usage for this program. - flag.Usage = func() { - _, program := filepath.Split(os.Args[0]) - fmt.Fprintf(os.Stderr, "Usage:\n %s [options] [matching_regexp]\n\n", program) - fmt.Fprintf(os.Stderr, "Options:\n") - flag.PrintDefaults() +// outputTable returns a tabular representation of the vault. +func outputTable(vault []otpEntry, flags cmdLineFlags) string { + // Don't print anything (even a header) if vault is empty. + if len(vault) == 0 { + return "" } - var ( - flagInput = flag.String("input", "", "Input (encrypted) JSON file glob.") - flagFuzzy = flag.Bool("fuzzy", false, "Use interactive fuzzy finder.") - flagVersion = flag.Bool("version", false, "Show program version and exit.") - ) + // If no interactive mode requested, print a table by default. + tbl := table.NewWriter() + + // "Plain" style and box used for fzf compatible output. + styleBoxPlain := table.BoxStyle{ + BottomLeft: "", + BottomRight: "", + BottomSeparator: "", + EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("+")), + Left: "", + LeftSeparator: "", + MiddleHorizontal: "", + MiddleSeparator: "", + MiddleVertical: "", + PaddingLeft: " ", + PaddingRight: " ", + PageSeparator: "", + Right: "", + RightSeparator: "", + TopLeft: "", + TopRight: "", + TopSeparator: "", + UnfinishedRow: "", + } + stylePlain := table.StyleDefault + stylePlain.Box = styleBoxPlain + + tbl.SetStyle(table.StyleLight) + if flags.plain { + tbl.SetStyle(stylePlain) + } + + // Don't use headers (or automerge) in the output for FZF. + if !flags.fzf { + tbl.AppendHeader(table.Row{"Issuer", "Name", "OTP"}) + } + + for _, v := range vault { + tbl.AppendRow(table.Row{v.issuer, v.account, v.token}) + } + + tbl.SortBy([]table.SortBy{ + {Name: "Issuer", Mode: table.Asc}, + {Name: "Name", Mode: table.Asc}, + }) + tbl.SetColumnConfigs([]table.ColumnConfig{{Number: 1, AutoMerge: !flags.fzf}}) + tbl.Style().Options.SeparateRows = false + return tbl.Render() +} + +// parseFlags parses the command line flags and returns a cmdLineFlag struct. +func parseFlags() (cmdLineFlags, error) { + flags := cmdLineFlags{} + + flag.StringVar(&flags.input, "input", "", "Input (encrypted) JSON file glob.") + flag.BoolVar(&flags.fuzzy, "fuzzy", false, "Use interactive fuzzy finder.") + flag.BoolVar(&flags.fzf, "fzf", false, "Use fzf (needs external binary in path).") + flag.BoolVar(&flags.plain, "plain", false, "Use plain output (disables fuzzy finder and tabular output.)") + flag.BoolVar(&flags.version, "version", false, "Show program version and exit.") flag.Parse() - if *flagVersion { + if flags.version { fmt.Printf("Build Version: %s\n", BuildVersion) - return + os.Exit(0) + } + + // Flag sanity checking. + if flags.input == "" { + return cmdLineFlags{}, errors.New("Please specify input file with --input") } - if *flagInput == "" { - usage("Please specify input file with --input") + // Only one output format allowed. + n := 0 + for _, v := range []bool{flags.plain, flags.fuzzy, flags.fzf} { + if v { + n++ + } + } + if n > 1 { + return cmdLineFlags{}, errors.New("Please only specify ONE output format.") } if len(flag.Args()) > 1 { - usage("Specify one or zero regular expressions to match.") + return cmdLineFlags{}, errors.New("Specify one or zero regular expressions to match.") + } + + // FZF uses plain output, with modifications (no headers, no automerge) + if flags.fzf { + flags.plain = true + } + + return flags, nil +} + +// fzf runs fzf on the output and return the chosen token. +func fzf(table string) (string, error) { + cmd := exec.Command("fzf", "--sync") + cmd.Stderr = os.Stderr + + stdin, err := cmd.StdinPipe() + if err != nil { + return "", err + } + // Generate output for fzf's stdin. + for _, line := range strings.Split(table, "\n") { + // Remove lines containing only spaces added + // by table. TODO: Find a better fix for this. + if strings.TrimSpace(line) == "" { + continue + } + fmt.Fprintln(stdin, line) + } + stdin.Close() + + output, err := cmd.Output() + if err != nil { + return "", err + } + + c := strings.TrimSpace(string(output)) + f := strings.Fields(c) + // This should not happen (empty line) + if len(f) < 1 { + return "", nil + } + // FZF returns the entire line. The last element contains the token. + return f[len(f)-1], nil +} + +func main() { + // Usage prints the default usage for this program. + flag.Usage = func() { + _, program := filepath.Split(os.Args[0]) + fmt.Fprintf(os.Stderr, "Usage:\n %s [options] [matching_regexp]\n\n", program) + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + } + + flags, err := parseFlags() + if err != nil { + die(err) } // Get input file from the input files glob. - input, err := inputFile(*flagInput) + input, err := inputFile(flags.input) if err != nil { die(err) } @@ -140,34 +265,23 @@ func main() { return key1 > key2 }) - // Interactive fuzzy finder. - if *flagFuzzy { - token, err := fuzzyFind(vault) + switch { + case flags.fuzzy: + // Interactive fuzzy finder. + if flags.fuzzy { + token, err := fuzzyFind(vault) + if err != nil { + die(err) + } + fmt.Println(token) + } + case flags.fzf: + t, err := fzf(outputTable(vault, flags)) if err != nil { die(err) } - fmt.Println(token) - return + fmt.Println(t) + default: + fmt.Println(outputTable(vault, flags)) } - - // If no interactive mode requested, print a table by default. - tbl := table.NewWriter() - automerge := table.RowConfig{AutoMerge: true} - - tbl.AppendHeader(table.Row{"Issuer", "Name", "OTP"}, automerge) - for _, v := range vault { - tbl.AppendRow(table.Row{v.issuer, v.account, v.token}, automerge) - } - - tbl.SortBy([]table.SortBy{ - {Name: "Issuer", Mode: table.Asc}, - {Name: "Name", Mode: table.Asc}, - }) - tbl.SetOutputMirror(os.Stdout) - tbl.SetColumnConfigs([]table.ColumnConfig{ - {Number: 1, AutoMerge: true}, - }) - tbl.SetStyle(table.StyleLight) - tbl.Style().Options.SeparateRows = true - tbl.Render() }