Customize the format of fzf's lines with your own template (#36)

pull/38/head
Mickaël Menu 3 years ago committed by GitHub
parent fa8226b263
commit 83b14ca827
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -2,7 +2,20 @@
All notable changes to this project will be documented in this file.
<!--## Unreleased-->
## Unreleased
### Added
* Customize the format of `fzf`'s lines [with your own template](docs/tool-fzf.md).
```toml
[tool]
fzf-line = "{{style 'green' path}}{{#each tags}} #{{this}}{{/each}} {{style 'black' body}}"
```
### Fixed
* Creating a new note from `fzf` in a directory containing spaces.
## 0.4.0

@ -23,3 +23,38 @@ Or, if you prefer to preview more metadata, you can use a nested `zk` command.
[tool]
fzf-preview = "zk list --quiet --format full --limit 1 {-1}"
```
## Line format
With the `fzf-line` setting property, you can provide your own [template](template.md) to customize the format of each `fzf` line. The lines are used by `fzf` for the fuzzy matching, so if you want to search in the full note content, do not forget to add `{{body}}` in your custom template.
The default line template is `{{style "title" title-or-path}} {{style "understate" body}}`.
Here's an example using different colors and showing the list of tags as #hashtags:
```toml
[tool]
fzf-line = "{{style 'blue' rel-path}}{{#each tags}} #{{this}}{{/each}} {{style 'black' body}}"
```
### Template context
The following variables are available in the line template.
| Variable | Type | Description |
|-----------------|----------|--------------------------------------------------------------------|
| `path` | string | File path to the note, relative to the notebook root |
| `abs-path` | string | Absolute file path to the note |
| `rel-path` | string | File path to the note, relative to the current directory |
| `title` | string | Note title |
| `title-or-path` | string | Note title or path if empty |
| `body` | string | All of the note content, minus the heading |
| `raw-content` | string | The full raw content of the note file |
| `word-count` | int | Number of words in the note |
| `tags` | [string] | List of tags found in the note |
| `metadata` | map | YAML frontmatter metadata, e.g. `metadata.description`<sup>1</sup> |
| `created` | date | Date of creation of the note |
| `modified` | date | Last date of modification of the note |
| `checksum` | string | SHA-256 checksum of the note file |
1. YAML keys are normalized to lower case.

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/mickael-menu/zk/internal/adapter/term"
"github.com/mickael-menu/zk/internal/core"
@ -13,9 +14,10 @@ import (
// NoteFilter uses fzf to filter interactively a set of notes.
type NoteFilter struct {
opts NoteFilterOpts
fs core.FileStorage
terminal *term.Terminal
opts NoteFilterOpts
fs core.FileStorage
terminal *term.Terminal
templateLoader core.TemplateLoader
}
// NoteFilterOpts holds the configuration for the fzf notes filtering.
@ -28,6 +30,8 @@ type NoteFilterOpts struct {
Interactive bool
// Indicates whether fzf is opened for every query, even if empty.
AlwaysFilter bool
// Format for a single line, taken from the config `fzf-line` property.
LineTemplate opt.String
// Preview command to run when selecting a note.
PreviewCmd opt.String
// When non null, a "create new note from query" binding will be added to
@ -37,11 +41,12 @@ type NoteFilterOpts struct {
NotebookDir string
}
func NewNoteFilter(opts NoteFilterOpts, fs core.FileStorage, terminal *term.Terminal) *NoteFilter {
func NewNoteFilter(opts NoteFilterOpts, fs core.FileStorage, terminal *term.Terminal, templateLoader core.TemplateLoader) *NoteFilter {
return &NoteFilter{
opts: opts,
fs: fs,
terminal: terminal,
opts: opts,
fs: fs,
terminal: terminal,
templateLoader: templateLoader,
}
}
@ -55,9 +60,19 @@ func (f *NoteFilter) Apply(notes []core.ContextualNote) ([]core.ContextualNote,
return notes, nil
}
lineTemplate, err := f.templateLoader.LoadTemplate(f.opts.LineTemplate.OrString(defaultLineTemplate).String())
if err != nil {
return selectedNotes, err
}
for _, note := range notes {
absPaths = append(absPaths, filepath.Join(f.opts.NotebookDir, note.Path))
relPaths = append(relPaths, note.Path)
absPath := filepath.Join(f.opts.NotebookDir, note.Path)
absPaths = append(absPaths, absPath)
if relPath, err := f.fs.Rel(absPath); err == nil {
relPaths = append(relPaths, relPath)
} else {
relPaths = append(relPaths, note.Path)
}
}
zkBin, err := os.Executable()
@ -76,7 +91,7 @@ func (f *NoteFilter) Apply(notes []core.ContextualNote) ([]core.ContextualNote,
bindings = append(bindings, Binding{
Keys: "Ctrl-N",
Description: "create a note with the query as title" + suffix,
Action: fmt.Sprintf("abort+execute(%s new %s --title {q} < /dev/tty > /dev/tty)", zkBin, dir.Path),
Action: fmt.Sprintf(`abort+execute("%s" new "%s" --title {q} < /dev/tty > /dev/tty)`, zkBin, dir.Path),
})
}
@ -92,15 +107,34 @@ func (f *NoteFilter) Apply(notes []core.ContextualNote) ([]core.ContextualNote,
}
for i, note := range notes {
title := note.Title
if title == "" {
title = relPaths[i]
context := lineRenderContext{
Path: note.Path,
AbsPath: absPaths[i],
RelPath: relPaths[i],
Title: note.Title,
TitleOrPath: note.Title,
Body: stringsutil.JoinLines(note.Body),
RawContent: stringsutil.JoinLines(note.RawContent),
WordCount: note.WordCount,
Tags: note.Tags,
Metadata: note.Metadata,
Created: note.Created,
Modified: note.Modified,
Checksum: note.Checksum,
}
fzf.Add([]string{
f.terminal.MustStyle(title, core.StyleYellow),
f.terminal.MustStyle(stringsutil.JoinLines(note.Body), core.StyleUnderstate),
f.terminal.MustStyle(absPaths[i], core.StyleUnderstate),
})
if context.TitleOrPath == "" {
context.TitleOrPath = note.Path
}
line, err := lineTemplate.Render(context)
if err != nil {
return selectedNotes, err
}
// The absolute path is appended at the end of the line to be used in
// the preview command.
absPathField := f.terminal.MustStyle(context.AbsPath, core.StyleUnderstate)
fzf.Add([]string{line, absPathField})
}
selection, err := fzf.Selection()
@ -119,3 +153,21 @@ func (f *NoteFilter) Apply(notes []core.ContextualNote) ([]core.ContextualNote,
return selectedNotes, nil
}
var defaultLineTemplate = `{{style "title" title-or-path}} {{style "understate" body}}`
type lineRenderContext struct {
Path string
AbsPath string `handlebars:"abs-path"`
RelPath string `handlebars:"rel-path"`
Title string
TitleOrPath string `handlebars:"title-or-path"`
Body string
RawContent string `handlebars:"raw-content"`
WordCount int `handlebars:"word-count"`
Tags []string
Metadata map[string]interface{}
Created time.Time
Modified time.Time
Checksum string
}

@ -36,7 +36,6 @@ func (cmd *Edit) Run(container *cli.Container) error {
filter := container.NewNoteFilter(fzf.NoteFilterOpts{
Interactive: cmd.Interactive,
AlwaysFilter: true,
PreviewCmd: container.Config.Tool.FzfPreview,
NewNoteDir: cmd.newNoteDir(notebook),
NotebookDir: notebook.Path,
})

@ -50,7 +50,6 @@ func (cmd *List) Run(container *cli.Container) error {
filter := container.NewNoteFilter(fzf.NoteFilterOpts{
Interactive: cmd.Interactive,
AlwaysFilter: false,
PreviewCmd: container.Config.Tool.FzfPreview,
NotebookDir: notebook.Path,
})

@ -33,6 +33,7 @@ type Container struct {
Logger *util.ProxyLogger
Terminal *term.Terminal
FS *fs.FileStorage
TemplateLoader core.TemplateLoader
WorkingDir string
Notebooks *core.NotebookStore
currentNotebook *core.Notebook
@ -49,6 +50,13 @@ func NewContainer(version string) (*Container, error) {
config := core.NewDefaultConfig()
handlebars.Init(term.SupportsUTF8(), logger)
// Template loader used for embedded templates (e.g. default config, fzf
// line, etc.).
templateLoader := handlebars.NewLoader(handlebars.LoaderOpts{
LookupPaths: []string{},
Styler: styler,
})
templateLoader.RegisterHelper("style", hbhelpers.NewStyleHelper(styler, logger))
// Load global user config
configPath, err := locateGlobalConfig()
@ -63,21 +71,15 @@ func NewContainer(version string) (*Container, error) {
}
return &Container{
Version: version,
Config: config,
Logger: logger,
Terminal: term,
FS: fs,
Version: version,
Config: config,
Logger: logger,
Terminal: term,
FS: fs,
TemplateLoader: templateLoader,
Notebooks: core.NewNotebookStore(config, core.NotebookStorePorts{
FS: fs,
TemplateLoaderFactory: func(language string) (core.TemplateLoader, error) {
loader := handlebars.NewLoader(handlebars.LoaderOpts{
LookupPaths: []string{},
Styler: styler,
})
return loader, nil
},
FS: fs,
TemplateLoader: templateLoader,
NotebookFactory: func(path string, config core.Config) (*core.Notebook, error) {
dbPath := filepath.Join(path, ".zk/notebook.db")
db, err := sqlite.Open(dbPath)
@ -192,7 +194,9 @@ func (c *Container) CurrentNotebook() (*core.Notebook, error) {
}
func (c *Container) NewNoteFilter(opts fzf.NoteFilterOpts) *fzf.NoteFilter {
return fzf.NewNoteFilter(opts, c.FS, c.Terminal)
opts.PreviewCmd = c.Config.Tool.FzfPreview
opts.LineTemplate = c.Config.Tool.FzfLine
return fzf.NewNoteFilter(opts, c.FS, c.Terminal, c.TemplateLoader)
}
func (c *Container) NewNoteEditor(notebook *core.Notebook) (*editor.Editor, error) {

@ -121,6 +121,7 @@ type ToolConfig struct {
Editor opt.String
Pager opt.String
FzfPreview opt.String
FzfLine opt.String
}
// NoteConfig holds the user configuration used when generating new notes.
@ -270,6 +271,9 @@ func ParseConfig(content []byte, path string, parentConfig Config) (Config, erro
if tool.FzfPreview != nil {
config.Tool.FzfPreview = opt.NewStringWithPtr(tool.FzfPreview)
}
if tool.FzfLine != nil {
config.Tool.FzfLine = opt.NewNotEmptyString(*tool.FzfLine)
}
// Filters
if tomlConf.Filters != nil {
@ -380,6 +384,7 @@ type tomlToolConfig struct {
Editor *string
Pager *string
FzfPreview *string `toml:"fzf-preview"`
FzfLine *string `toml:"fzf-line"`
}
func charsetFromString(charset string) Charset {

@ -41,6 +41,7 @@ func TestParseDefaultConfig(t *testing.T) {
Editor: opt.NullString,
Pager: opt.NullString,
FzfPreview: opt.NullString,
FzfLine: opt.NullString,
},
Filters: make(map[string]string),
Aliases: make(map[string]string),
@ -79,6 +80,7 @@ func TestParseComplete(t *testing.T) {
editor = "vim"
pager = "less"
fzf-preview = "bat {1}"
fzf-line = "{{title}}"
[extra]
hello = "world"
@ -203,6 +205,7 @@ func TestParseComplete(t *testing.T) {
Editor: opt.NewString("vim"),
Pager: opt.NewString("less"),
FzfPreview: opt.NewString("bat {1}"),
FzfLine: opt.NewString("{{title}}"),
},
Filters: map[string]string{
"recents": "--created-after '2 weeks ago'",

@ -9,30 +9,30 @@ import (
// NotebookStore retrieves or creates new notebooks.
type NotebookStore struct {
config Config
notebookFactory NotebookFactory
templateLoaderFactory TemplateLoaderFactory
fs FileStorage
config Config
notebookFactory NotebookFactory
templateLoader TemplateLoader
fs FileStorage
// Cached opened notebooks.
notebooks map[string]*Notebook
}
type NotebookStorePorts struct {
NotebookFactory NotebookFactory
TemplateLoaderFactory TemplateLoaderFactory
FS FileStorage
NotebookFactory NotebookFactory
TemplateLoader TemplateLoader
FS FileStorage
}
// NewNotebookStore creates a new NotebookStore instance using the given
// options and port implementations.
func NewNotebookStore(config Config, ports NotebookStorePorts) *NotebookStore {
return &NotebookStore{
config: config,
notebookFactory: ports.NotebookFactory,
templateLoaderFactory: ports.TemplateLoaderFactory,
fs: ports.FS,
notebooks: map[string]*Notebook{},
config: config,
notebookFactory: ports.NotebookFactory,
templateLoader: ports.TemplateLoader,
fs: ports.FS,
notebooks: map[string]*Notebook{},
}
}
@ -160,11 +160,7 @@ func (ns *NotebookStore) locateNotebook(path string) (string, error) {
}
func (ns *NotebookStore) generateConfig(options InitOpts) (string, error) {
loader, err := ns.templateLoaderFactory("en")
if err != nil {
return "", err
}
template, err := loader.LoadTemplate(defaultConfig)
template, err := ns.templateLoader.LoadTemplate(defaultConfig)
if err != nil {
return "", err
}

Loading…
Cancel
Save