Filter notes interactively with fzf

pull/6/head
Mickaël Menu 3 years ago
parent 5c59d7946b
commit fe6b062309
No known key found for this signature in database
GPG Key ID: 53D73664CD359895

@ -0,0 +1,73 @@
package fzf
import (
"fmt"
"io"
"strings"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/style"
stringsutil "github.com/mickael-menu/zk/util/strings"
)
// NoteFinder wraps a note.Finder and filters its result interactively using fzf.
type NoteFinder struct {
finder note.Finder
styler style.Styler
}
func NewNoteFinder(finder note.Finder, styler style.Styler) *NoteFinder {
return &NoteFinder{finder, styler}
}
func (f *NoteFinder) Find(opts note.FinderOpts) ([]note.Match, error) {
isInteractive, opts := popInteractiveFilter(opts)
matches, err := f.finder.Find(opts)
if !isInteractive || err != nil {
return matches, err
}
selectedMatches := make([]note.Match, 0)
selection, err := withFzf(func(fzf io.Writer) error {
for _, match := range matches {
fmt.Fprintf(fzf, "%v\x01 %v %v\n",
match.Path,
f.styler.MustStyle(match.Title, style.Rule("yellow")),
f.styler.MustStyle(stringsutil.JoinLines(match.Body), style.Rule("faint")),
)
}
return nil
})
if err != nil {
return selectedMatches, err
}
for _, s := range selection {
path := strings.Split(s, "\x01")[0]
for _, m := range matches {
if m.Path == path {
selectedMatches = append(selectedMatches, m)
}
}
}
return selectedMatches, nil
}
func popInteractiveFilter(opts note.FinderOpts) (bool, note.FinderOpts) {
isInteractive := false
filters := make([]note.Filter, 0)
for _, filter := range opts.Filters {
if f, ok := filter.(note.InteractiveFilter); ok {
isInteractive = bool(f)
} else {
filters = append(filters, filter)
}
}
opts.Filters = filters
return isInteractive, opts
}

@ -0,0 +1,57 @@
package fzf
import (
"io"
"os"
"os/exec"
"github.com/mickael-menu/zk/util/strings"
)
func withFzf(callback func(fzf io.Writer) error) ([]string, error) {
zkBin, err := os.Executable()
if err != nil {
return []string{}, err
}
cmd := exec.Command(
"fzf",
"--delimiter", "\x01",
"--tiebreak", "begin",
"--ansi",
"--exact",
"--height", "100%",
// FIXME: Use it to create a new note? Like notational velocity
// "--print-query",
// Make sure the path and titles are always visible
"--no-hscroll",
"--tabstop", "4",
// Don't highlight search terms
"--color", "hl:-1,hl+:-1",
// "--preview", `bat -p --theme Nord --color always {1}`,
"--preview", zkBin+" list -f {{raw-content}} {1}",
"--preview-window", "noborder:wrap",
)
cmd.Stderr = os.Stderr
w, err := cmd.StdinPipe()
if err != nil {
return []string{}, err
}
var callbackErr error
go func() {
callbackErr = callback(w)
w.Close()
}()
output, err := cmd.Output()
if callbackErr != nil {
return []string{}, callbackErr
}
if err != nil {
return []string{}, err
}
return strings.SplitLines(string(output)), nil
}

@ -20,10 +20,14 @@ func init() {
type styler struct{} type styler struct{}
func (s *styler) Style(text string, rules ...style.Rule) (string, error) { func (s *styler) Style(text string, rules ...style.Rule) (string, error) {
return s.MustStyle(text, rules...), nil
}
func (s *styler) MustStyle(text string, rules ...style.Rule) string {
for _, rule := range rules { for _, rule := range rules {
text = fmt.Sprintf("%s(%s)", rule, text) text = fmt.Sprintf("%s(%s)", rule, text)
} }
return text, nil return text
} }
func testString(t *testing.T, template string, context interface{}, expected string) { func testString(t *testing.T, template string, context interface{}, expected string) {

@ -154,17 +154,16 @@ func (d *NoteDAO) exists(path string) (bool, error) {
return exists, nil return exists, nil
} }
func (d *NoteDAO) Find(opts note.FinderOpts, callback func(note.Match) error) (int, error) { func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
matches := make([]note.Match, 0)
rows, err := d.findRows(opts) rows, err := d.findRows(opts)
if err != nil { if err != nil {
return 0, err return matches, err
} }
defer rows.Close() defer rows.Close()
count := 0
for rows.Next() { for rows.Next() {
count++
var ( var (
id, wordCount int id, wordCount int
title, lead, body, rawContent, snippet string title, lead, body, rawContent, snippet string
@ -178,7 +177,7 @@ func (d *NoteDAO) Find(opts note.FinderOpts, callback func(note.Match) error) (i
continue continue
} }
callback(note.Match{ matches = append(matches, note.Match{
Snippet: snippet, Snippet: snippet,
Metadata: note.Metadata{ Metadata: note.Metadata{
Path: path, Path: path,
@ -194,7 +193,7 @@ func (d *NoteDAO) Find(opts note.FinderOpts, callback func(note.Match) error) (i
}) })
} }
return count, nil return matches, nil
} }
type findQuery struct { type findQuery struct {
@ -253,6 +252,10 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
whereExprs = append(whereExprs, fmt.Sprintf("%s %s %s", field, op, value)) whereExprs = append(whereExprs, fmt.Sprintf("%s %s %s", field, op, value))
args = append(args, filter.Date) args = append(args, filter.Date)
case note.InteractiveFilter:
// No user interaction possible from here.
break
default: default:
panic(fmt.Sprintf("%v: unknown filter type", filter)) panic(fmt.Sprintf("%v: unknown filter type", filter))
} }

@ -469,26 +469,21 @@ func testNoteDAOFindSort(t *testing.T, field note.SortField, ascending bool, exp
func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string) { func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
matches, err := dao.Find(opts)
assert.Nil(t, err)
actual := make([]string, 0) actual := make([]string, 0)
count, err := dao.Find(opts, func(m note.Match) error { for _, m := range matches {
actual = append(actual, m.Path) actual = append(actual, m.Path)
return nil }
})
assert.Nil(t, err)
assert.Equal(t, count, len(expected))
assert.Equal(t, actual, expected) assert.Equal(t, actual, expected)
}) })
} }
func testNoteDAOFind(t *testing.T, opts note.FinderOpts, expected []note.Match) { func testNoteDAOFind(t *testing.T, opts note.FinderOpts, expected []note.Match) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
actual := make([]note.Match, 0) actual, err := dao.Find(opts)
count, err := dao.Find(opts, func(m note.Match) error {
actual = append(actual, m)
return nil
})
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, count, len(expected))
assert.Equal(t, actual, expected) assert.Equal(t, actual, expected)
}) })
} }

@ -3,10 +3,12 @@ package cmd
import ( import (
"io" "io"
"github.com/mickael-menu/zk/adapter/fzf"
"github.com/mickael-menu/zk/adapter/handlebars" "github.com/mickael-menu/zk/adapter/handlebars"
"github.com/mickael-menu/zk/adapter/markdown" "github.com/mickael-menu/zk/adapter/markdown"
"github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/adapter/sqlite"
"github.com/mickael-menu/zk/adapter/tty" "github.com/mickael-menu/zk/adapter/tty"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util" "github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/date" "github.com/mickael-menu/zk/util/date"
@ -46,6 +48,11 @@ func (c *Container) Parser() *markdown.Parser {
return markdown.NewParser() return markdown.NewParser()
} }
func (c *Container) NoteFinder(tx sqlite.Transaction) note.Finder {
notes := sqlite.NewNoteDAO(tx, c.Logger)
return fzf.NewNoteFinder(notes, c.Styler())
}
// Database returns the DB instance for the given slip box, after executing any // Database returns the DB instance for the given slip box, after executing any
// pending migration. // pending migration.
func (c *Container) Database(path string) (*sqlite.DB, error) { func (c *Container) Database(path string) (*sqlite.DB, error) {

@ -3,6 +3,7 @@ package cmd
import ( import (
"fmt" "fmt"
"io" "io"
"os"
"time" "time"
"github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/adapter/sqlite"
@ -28,6 +29,7 @@ type List struct {
ModifiedAfter string `help:"Show only the notes modified after the given date" placeholder:"<date>"` ModifiedAfter string `help:"Show only the notes modified after the given date" placeholder:"<date>"`
Exclude []string `help:"Excludes notes matching the given file path pattern from the list" short:"x" placeholder:"<glob>"` Exclude []string `help:"Excludes notes matching the given file path pattern from the list" short:"x" placeholder:"<glob>"`
Sort []string `help:"Sort the notes by the given criterion" short:"s" placeholder:"<term>"` Sort []string `help:"Sort the notes by the given criterion" short:"s" placeholder:"<term>"`
Interactive bool `help:"Further filter the list of notes interactively" short:"i"`
NoPager bool `help:"Do not pipe zk output into a pager" short:"P"` NoPager bool `help:"Do not pipe zk output into a pager" short:"P"`
} }
@ -37,7 +39,7 @@ func (cmd *List) Run(container *Container) error {
return err return err
} }
opts, err := cmd.ListOpts(zk) opts, err := cmd.FinderOpts(zk)
if err != nil { if err != nil {
return errors.Wrapf(err, "incorrect criteria") return errors.Wrapf(err, "incorrect criteria")
} }
@ -47,33 +49,55 @@ func (cmd *List) Run(container *Container) error {
return err return err
} }
logger := container.Logger wd, err := os.Getwd()
if err != nil {
return err
}
return db.WithTransaction(func(tx sqlite.Transaction) error { templates := container.TemplateLoader(zk.Config.Lang)
notes := sqlite.NewNoteDAO(tx, logger) styler := container.Styler()
format := opt.NewNotEmptyString(cmd.Format)
formatter, err := note.NewFormatter(zk.Path, wd, format, templates, styler)
if err != nil {
return err
}
deps := note.ListDeps{ var notes []note.Match
BasePath: zk.Path, err = db.WithTransaction(func(tx sqlite.Transaction) error {
Finder: notes, notes, err = container.NoteFinder(tx).Find(*opts)
Templates: container.TemplateLoader(zk.Config.Lang), return err
Styler: container.Styler(), })
} if err != nil {
return err
}
count := 0 count := len(notes)
if count > 0 {
err = container.Paginate(cmd.NoPager, zk.Config, func(out io.Writer) error { err = container.Paginate(cmd.NoPager, zk.Config, func(out io.Writer) error {
count, err = note.List(*opts, deps, out) for _, note := range notes {
return err ft, err := formatter.Format(note)
if err != nil {
return err
}
_, err = fmt.Fprintf(out, "%v\n", ft)
if err != nil {
return err
}
}
return nil
}) })
}
if err == nil { if err == nil {
fmt.Printf("\nFound %d %s\n", count, strings.Pluralize("result", count)) fmt.Printf("\nFound %d %s\n", count, strings.Pluralize("result", count))
} }
return err return err
})
} }
func (cmd *List) ListOpts(zk *zk.Zk) (*note.ListOpts, error) { func (cmd *List) FinderOpts(zk *zk.Zk) (*note.FinderOpts, error) {
filters := make([]note.Filter, 0) filters := make([]note.Filter, 0)
paths, ok := relPaths(zk, cmd.Path) paths, ok := relPaths(zk, cmd.Path)
@ -162,18 +186,19 @@ func (cmd *List) ListOpts(zk *zk.Zk) (*note.ListOpts, error) {
}) })
} }
if cmd.Interactive {
filters = append(filters, note.InteractiveFilter(true))
}
sorters, err := note.SortersFromStrings(cmd.Sort) sorters, err := note.SortersFromStrings(cmd.Sort)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &note.ListOpts{ return &note.FinderOpts{
Format: opt.NewNotEmptyString(cmd.Format), Filters: filters,
FinderOpts: note.FinderOpts{ Sorters: sorters,
Filters: filters, Limit: cmd.Limit,
Sorters: sorters,
Limit: cmd.Limit,
},
}, nil }, nil
} }

@ -11,7 +11,7 @@ import (
// //
// Returns the number of matches found. // Returns the number of matches found.
type Finder interface { type Finder interface {
Find(opts FinderOpts, callback func(Match) error) (int, error) Find(opts FinderOpts) ([]Match, error)
} }
// FinderOpts holds the option used to filter and order a list of notes. // FinderOpts holds the option used to filter and order a list of notes.
@ -40,11 +40,6 @@ type PathFilter []string
// ExcludePathFilter is a note filter using path globs to exclude notes from the list. // ExcludePathFilter is a note filter using path globs to exclude notes from the list.
type ExcludePathFilter []string type ExcludePathFilter []string
func (f MatchFilter) sealed() {}
func (f PathFilter) sealed() {}
func (f ExcludePathFilter) sealed() {}
func (f DateFilter) sealed() {}
// DateFilter can be used to filter notes created or modified before, after or on a given date. // DateFilter can be used to filter notes created or modified before, after or on a given date.
type DateFilter struct { type DateFilter struct {
Date time.Time Date time.Time
@ -52,6 +47,15 @@ type DateFilter struct {
Field DateField Field DateField
} }
// InteractiveFilter lets the user select manually the notes.
type InteractiveFilter bool
func (f MatchFilter) sealed() {}
func (f PathFilter) sealed() {}
func (f ExcludePathFilter) sealed() {}
func (f DateFilter) sealed() {}
func (f InteractiveFilter) sealed() {}
type DateDirection int type DateDirection int
const ( const (

@ -63,24 +63,28 @@ var formatTemplates = map[string]string{
"short": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) "short": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{prepend " " snippet}}`, {{prepend " " snippet}}
`,
"medium": `{{style "title" title}} {{style "path" path}} "medium": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
{{prepend " " snippet}}`, {{prepend " " snippet}}
`,
"long": `{{style "title" title}} {{style "path" path}} "long": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
Modified: {{date created "short"}} Modified: {{date created "short"}}
{{prepend " " snippet}}`, {{prepend " " snippet}}
`,
"full": `{{style "title" title}} {{style "path" path}} "full": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
Modified: {{date created "short"}} Modified: {{date created "short"}}
{{prepend " " body}}`, {{prepend " " body}}
`,
} }
var termRegex = regexp.MustCompile(`<zk:match>(.*?)</zk:match>`) var termRegex = regexp.MustCompile(`<zk:match>(.*?)</zk:match>`)

@ -21,7 +21,8 @@ func TestDefaultFormat(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, res, `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) assert.Equal(t, res, `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{prepend " " snippet}}`) {{prepend " " snippet}}
`)
} }
func TestFormats(t *testing.T) { func TestFormats(t *testing.T) {
@ -39,24 +40,28 @@ func TestFormats(t *testing.T) {
test("short", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) test("short", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{prepend " " snippet}}`) {{prepend " " snippet}}
`)
test("medium", `{{style "title" title}} {{style "path" path}} test("medium", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
{{prepend " " snippet}}`) {{prepend " " snippet}}
`)
test("long", `{{style "title" title}} {{style "path" path}} test("long", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
Modified: {{date created "short"}} Modified: {{date created "short"}}
{{prepend " " snippet}}`) {{prepend " " snippet}}
`)
test("full", `{{style "title" title}} {{style "path" path}} test("full", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
Modified: {{date created "short"}} Modified: {{date created "short"}}
{{prepend " " body}}`) {{prepend " " body}}
`)
// Known formats are case sensitive. // Known formats are case sensitive.
test("Path", "Path") test("Path", "Path")

@ -1,46 +0,0 @@
package note
import (
"fmt"
"io"
"os"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/core/templ"
"github.com/mickael-menu/zk/util/opt"
)
type ListOpts struct {
Format opt.String
FinderOpts
}
type ListDeps struct {
BasePath string
Finder Finder
Templates templ.Loader
Styler style.Styler
}
// List finds notes matching given criteria and formats them according to user
// preference.
func List(opts ListOpts, deps ListDeps, out io.Writer) (int, error) {
wd, err := os.Getwd()
if err != nil {
return 0, err
}
formatter, err := NewFormatter(deps.BasePath, wd, opts.Format, deps.Templates, deps.Styler)
if err != nil {
return 0, err
}
return deps.Finder.Find(opts.FinderOpts, func(note Match) error {
ft, err := formatter.Format(note)
if err != nil {
return err
}
_, err = fmt.Fprintln(out, ft)
return err
})
}

@ -66,8 +66,12 @@ func (m *RendererSpy) Render(context interface{}) (string, error) {
type StylerMock struct{} type StylerMock struct{}
func (s *StylerMock) Style(text string, rules ...style.Rule) (string, error) { func (s *StylerMock) Style(text string, rules ...style.Rule) (string, error) {
return s.MustStyle(text, rules...), nil
}
func (s *StylerMock) MustStyle(text string, rules ...style.Rule) string {
for _, rule := range rules { for _, rule := range rules {
text = fmt.Sprintf("%s(%s)", rule, text) text = fmt.Sprintf("%s(%s)", rule, text)
} }
return text, nil return text
} }

@ -5,6 +5,7 @@ package style
// A rule key can be either semantic, e.g. "title" or explicit, e.g. "red". // A rule key can be either semantic, e.g. "title" or explicit, e.g. "red".
type Styler interface { type Styler interface {
Style(text string, rules ...Rule) (string, error) Style(text string, rules ...Rule) (string, error)
MustStyle(text string, rules ...Rule) string
} }
// Rule is a key representing a single styling rule. // Rule is a key representing a single styling rule.

Loading…
Cancel
Save