From 83b14ca82729cfd691d502c95af932777436e8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Sun, 25 Apr 2021 12:40:53 +0200 Subject: [PATCH] Customize the format of fzf's lines with your own template (#36) --- CHANGELOG.md | 15 ++++- docs/tool-fzf.md | 35 ++++++++++++ internal/adapter/fzf/note_filter.go | 88 +++++++++++++++++++++++------ internal/cli/cmd/edit.go | 1 - internal/cli/cmd/list.go | 1 - internal/cli/container.go | 34 ++++++----- internal/core/config.go | 5 ++ internal/core/config_test.go | 3 + internal/core/notebook_store.go | 30 +++++----- 9 files changed, 159 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a2018..21cb280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,20 @@ All notable changes to this project will be documented in this file. - +## 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 diff --git a/docs/tool-fzf.md b/docs/tool-fzf.md index b92adad..804fa33 100644 --- a/docs/tool-fzf.md +++ b/docs/tool-fzf.md @@ -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`1 | +| `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. diff --git a/internal/adapter/fzf/note_filter.go b/internal/adapter/fzf/note_filter.go index d7b9a48..42a0cc9 100644 --- a/internal/adapter/fzf/note_filter.go +++ b/internal/adapter/fzf/note_filter.go @@ -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 +} diff --git a/internal/cli/cmd/edit.go b/internal/cli/cmd/edit.go index dd27b19..062b535 100644 --- a/internal/cli/cmd/edit.go +++ b/internal/cli/cmd/edit.go @@ -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, }) diff --git a/internal/cli/cmd/list.go b/internal/cli/cmd/list.go index 7fd33ba..700b648 100644 --- a/internal/cli/cmd/list.go +++ b/internal/cli/cmd/list.go @@ -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, }) diff --git a/internal/cli/container.go b/internal/cli/container.go index 221cc12..43a4798 100644 --- a/internal/cli/container.go +++ b/internal/cli/container.go @@ -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) { diff --git a/internal/core/config.go b/internal/core/config.go index 74e4726..bd308b2 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -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 { diff --git a/internal/core/config_test.go b/internal/core/config_test.go index acae498..b8e2544 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -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'", diff --git a/internal/core/notebook_store.go b/internal/core/notebook_store.go index 48603f8..d23e58c 100644 --- a/internal/core/notebook_store.go +++ b/internal/core/notebook_store.go @@ -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 }