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{}
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 {
text = fmt.Sprintf("%s(%s)", rule, text)
}
return text, nil
return text
}
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
}
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)
if err != nil {
return 0, err
return matches, err
}
defer rows.Close()
count := 0
for rows.Next() {
count++
var (
id, wordCount int
title, lead, body, rawContent, snippet string
@ -178,7 +177,7 @@ func (d *NoteDAO) Find(opts note.FinderOpts, callback func(note.Match) error) (i
continue
}
callback(note.Match{
matches = append(matches, note.Match{
Snippet: snippet,
Metadata: note.Metadata{
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 {
@ -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))
args = append(args, filter.Date)
case note.InteractiveFilter:
// No user interaction possible from here.
break
default:
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) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
matches, err := dao.Find(opts)
assert.Nil(t, err)
actual := make([]string, 0)
count, err := dao.Find(opts, func(m note.Match) error {
for _, m := range matches {
actual = append(actual, m.Path)
return nil
})
assert.Nil(t, err)
assert.Equal(t, count, len(expected))
}
assert.Equal(t, actual, expected)
})
}
func testNoteDAOFind(t *testing.T, opts note.FinderOpts, expected []note.Match) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
actual := make([]note.Match, 0)
count, err := dao.Find(opts, func(m note.Match) error {
actual = append(actual, m)
return nil
})
actual, err := dao.Find(opts)
assert.Nil(t, err)
assert.Equal(t, count, len(expected))
assert.Equal(t, actual, expected)
})
}

@ -3,10 +3,12 @@ package cmd
import (
"io"
"github.com/mickael-menu/zk/adapter/fzf"
"github.com/mickael-menu/zk/adapter/handlebars"
"github.com/mickael-menu/zk/adapter/markdown"
"github.com/mickael-menu/zk/adapter/sqlite"
"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/util"
"github.com/mickael-menu/zk/util/date"
@ -46,6 +48,11 @@ func (c *Container) Parser() *markdown.Parser {
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
// pending migration.
func (c *Container) Database(path string) (*sqlite.DB, error) {

@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"io"
"os"
"time"
"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>"`
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>"`
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"`
}
@ -37,7 +39,7 @@ func (cmd *List) Run(container *Container) error {
return err
}
opts, err := cmd.ListOpts(zk)
opts, err := cmd.FinderOpts(zk)
if err != nil {
return errors.Wrapf(err, "incorrect criteria")
}
@ -47,33 +49,55 @@ func (cmd *List) Run(container *Container) error {
return err
}
logger := container.Logger
wd, err := os.Getwd()
if err != nil {
return err
}
return db.WithTransaction(func(tx sqlite.Transaction) error {
notes := sqlite.NewNoteDAO(tx, logger)
templates := container.TemplateLoader(zk.Config.Lang)
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{
BasePath: zk.Path,
Finder: notes,
Templates: container.TemplateLoader(zk.Config.Lang),
Styler: container.Styler(),
}
var notes []note.Match
err = db.WithTransaction(func(tx sqlite.Transaction) error {
notes, err = container.NoteFinder(tx).Find(*opts)
return err
})
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 {
count, err = note.List(*opts, deps, out)
return err
for _, note := range notes {
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 {
fmt.Printf("\nFound %d %s\n", count, strings.Pluralize("result", count))
}
if err == nil {
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)
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)
if err != nil {
return nil, err
}
return &note.ListOpts{
Format: opt.NewNotEmptyString(cmd.Format),
FinderOpts: note.FinderOpts{
Filters: filters,
Sorters: sorters,
Limit: cmd.Limit,
},
return &note.FinderOpts{
Filters: filters,
Sorters: sorters,
Limit: cmd.Limit,
}, nil
}

@ -11,7 +11,7 @@ import (
//
// Returns the number of matches found.
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.
@ -40,11 +40,6 @@ type PathFilter []string
// ExcludePathFilter is a note filter using path globs to exclude notes from the list.
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.
type DateFilter struct {
Date time.Time
@ -52,6 +47,15 @@ type DateFilter struct {
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
const (

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

@ -21,7 +21,8 @@ func TestDefaultFormat(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, res, `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{prepend " " snippet}}`)
{{prepend " " snippet}}
`)
}
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"}})
{{prepend " " snippet}}`)
{{prepend " " snippet}}
`)
test("medium", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
{{prepend " " snippet}}`)
{{prepend " " snippet}}
`)
test("long", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
{{prepend " " snippet}}`)
{{prepend " " snippet}}
`)
test("full", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
{{prepend " " body}}`)
{{prepend " " body}}
`)
// Known formats are case sensitive.
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{}
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 {
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".
type Styler interface {
Style(text string, rules ...Rule) (string, error)
MustStyle(text string, rules ...Rule) string
}
// Rule is a key representing a single styling rule.

Loading…
Cancel
Save