From fa66e78068d5b1db69a108624482a0cb6030dd1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 24 Mar 2021 21:06:32 +0100 Subject: [PATCH] Add named filters (#17) --- CHANGELOG.md | 9 ++ README.md | 2 +- cmd/edit.go | 4 +- cmd/finder_opts.go | 118 +++++++++++++++++--- cmd/finder_opts_test.go | 204 +++++++++++++++++++++++++++++++++++ cmd/list.go | 4 +- core/zk/config.go | 10 ++ core/zk/config_test.go | 10 ++ core/zk/zk.go | 12 +++ docs/automation.md | 2 +- docs/config-alias.md | 2 + docs/config-filter.md | 49 +++++++++ docs/config.md | 4 + docs/note-filtering.md | 2 +- util/strings/strings.go | 10 ++ util/strings/strings_test.go | 13 +++ 16 files changed, 433 insertions(+), 22 deletions(-) create mode 100644 cmd/finder_opts_test.go create mode 100644 docs/config-filter.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ad35d..e49b743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ All notable changes to this project will be documented in this file. * Find every note whose title is mentioned in the note you are working on with `--mentioned-by file.md`. * To refer to a note using several names, you can use the [YAML frontmatter key `aliases`](https://publish.obsidian.md/help/How+to/Add+aliases+to+note). For example the note titled "Artificial Intelligence" might have: `aliases: [AI, robot]` * To find only unlinked mentions, pair it with `--no-linked-by`, e.g. `--mentioned-by file.md --no-linked-by file.md`. +* Declare [named filters](docs/config-filter.md) in the configuration file to reuse [note filtering options](docs/note-filtering.md) used frequently together, for example: + ```toml + [filter] + recents = "--sort created- --created-after 'last two weeks'" + ``` + ```sh + $ zk list recents --limit 10 + $ zk edit recents --interactive + ``` ### Fixed diff --git a/README.md b/README.md index 4061beb..8cd35b1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ * [Creating notes from templates](docs/note-creation.md) * [Advanced search and filtering capabilities](docs/note-filtering.md) including [tags](docs/tags.md), links and mentions * [Interactive browser](docs/tool-fzf), powered by `fzf` -* [Git-style command aliases](docs/config-alias.md) +* [Git-style command aliases](docs/config-alias.md) and [named filters](docs/config-filter.md) * [Made with automation in mind](docs/automation.md) * [Notebook housekeeping](docs/notebook-housekeeping.md) * [Future-proof, thanks to Markdown](docs/future-proof.md) diff --git a/cmd/edit.go b/cmd/edit.go index 518cbd8..4a5026e 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -14,9 +14,7 @@ import ( // Edit opens notes matching a set of criteria with the user editor. type Edit struct { Force bool `short:f help:"Do not confirm before editing many notes at the same time."` - Filtering - Sorting } func (cmd *Edit) Run(container *Container) error { @@ -25,7 +23,7 @@ func (cmd *Edit) Run(container *Container) error { return err } - opts, err := NewFinderOpts(zk, cmd.Filtering, cmd.Sorting) + opts, err := NewFinderOpts(zk, cmd.Filtering) if err != nil { return errors.Wrapf(err, "incorrect criteria") } diff --git a/cmd/finder_opts.go b/cmd/finder_opts.go index 23a5f5a..0ff2866 100644 --- a/cmd/finder_opts.go +++ b/cmd/finder_opts.go @@ -1,12 +1,17 @@ package cmd import ( + "fmt" "strconv" "time" + "github.com/alecthomas/kong" + "github.com/kballard/go-shellquote" "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/core/zk" + "github.com/mickael-menu/zk/util/errors" "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/util/strings" "github.com/tj/go-naturaldate" ) @@ -19,14 +24,14 @@ type Filtering struct { Match string `group:filter short:m placeholder:QUERY help:"Terms to search for in the notes."` Exclude []string `group:filter short:x placeholder:PATH help:"Ignore notes matching the given path, including its descendants."` Tag []string `group:filter short:t help:"Find notes tagged with the given tags."` - Mention []string `group:filter placeholder:PATH help:"Find notes mentioning the title of the given ones." xor:mention` - MentionedBy []string `group:filter placeholder:PATH help:"Find notes whose title is mentioned in the given ones." xor:mention` - LinkTo []string `group:filter short:l placeholder:PATH help:"Find notes which are linking to the given ones." xor:link` - NoLinkTo []string `group:filter placeholder:PATH help:"Find notes which are not linking to the given notes." xor:link` - LinkedBy []string `group:filter short:L placeholder:PATH help:"Find notes which are linked by the given ones." xor:link` - NoLinkedBy []string `group:filter placeholder:PATH help:"Find notes which are not linked by the given ones." xor:link` - Orphan bool `group:filter help:"Find notes which are not linked by any other note." xor:link` - Related []string `group:filter placeholder:PATH help:"Find notes which might be related to the given ones." xor:link` + Mention []string `group:filter placeholder:PATH help:"Find notes mentioning the title of the given ones."` + MentionedBy []string `group:filter placeholder:PATH help:"Find notes whose title is mentioned in the given ones."` + LinkTo []string `group:filter short:l placeholder:PATH help:"Find notes which are linking to the given ones."` + NoLinkTo []string `group:filter placeholder:PATH help:"Find notes which are not linking to the given notes."` + LinkedBy []string `group:filter short:L placeholder:PATH help:"Find notes which are linked by the given ones."` + NoLinkedBy []string `group:filter placeholder:PATH help:"Find notes which are not linked by the given ones."` + Orphan bool `group:filter help:"Find notes which are not linked by any other note."` + Related []string `group:filter placeholder:PATH help:"Find notes which might be related to the given ones."` MaxDistance int `group:filter placeholder:COUNT help:"Maximum distance between two linked notes."` Recursive bool `group:filter short:r help:"Follow links recursively."` Created string `group:filter placeholder:DATE help:"Find notes created on the given date."` @@ -35,15 +40,102 @@ type Filtering struct { Modified string `group:filter placeholder:DATE help:"Find notes modified on the given date."` ModifiedBefore string `group:filter placeholder:DATE help:"Find notes modified before the given date."` ModifiedAfter string `group:filter placeholder:DATE help:"Find notes modified after the given date."` -} -// Sorting holds sorting options to order notes. -type Sorting struct { Sort []string `group:sort short:s placeholder:TERM help:"Order the notes by the given criterion."` } +// ExpandNamedFilters expands recursively any named filter found in the Path field. +func (f Filtering) ExpandNamedFilters(filters map[string]string, expandedFilters []string) (Filtering, error) { + actualPaths := []string{} + + for _, path := range f.Path { + if filter, ok := filters[path]; ok && !strings.InList(expandedFilters, path) { + wrap := errors.Wrapperf("failed to expand named filter `%v`", path) + + var parsedFilter Filtering + parser, err := kong.New(&parsedFilter) + if err != nil { + return f, wrap(err) + } + args, err := shellquote.Split(filter) + if err != nil { + return f, wrap(err) + } + _, err = parser.Parse(args) + if err != nil { + return f, wrap(err) + } + + // Expand recursively, but prevent infinite loops by registering + // the current filter in the list of expanded filters. + parsedFilter, err = parsedFilter.ExpandNamedFilters(filters, append(expandedFilters, path)) + if err != nil { + return f, err + } + + actualPaths = append(actualPaths, parsedFilter.Path...) + f.Exclude = append(f.Exclude, parsedFilter.Exclude...) + f.Tag = append(f.Tag, parsedFilter.Tag...) + f.Mention = append(f.Mention, parsedFilter.Mention...) + f.MentionedBy = append(f.MentionedBy, parsedFilter.MentionedBy...) + f.LinkTo = append(f.LinkTo, parsedFilter.LinkTo...) + f.NoLinkTo = append(f.NoLinkTo, parsedFilter.NoLinkTo...) + f.LinkedBy = append(f.LinkedBy, parsedFilter.LinkedBy...) + f.NoLinkedBy = append(f.NoLinkedBy, parsedFilter.NoLinkedBy...) + f.Related = append(f.Related, parsedFilter.Related...) + f.Sort = append(f.Sort, parsedFilter.Sort...) + + f.Interactive = f.Interactive || parsedFilter.Interactive + f.Orphan = f.Orphan || parsedFilter.Orphan + f.Recursive = f.Recursive || parsedFilter.Recursive + + if f.Limit == 0 { + f.Limit = parsedFilter.Limit + } + if f.MaxDistance == 0 { + f.MaxDistance = parsedFilter.MaxDistance + } + if f.Created == "" { + f.Created = parsedFilter.Created + } + if f.CreatedBefore == "" { + f.CreatedBefore = parsedFilter.CreatedBefore + } + if f.CreatedAfter == "" { + f.CreatedAfter = parsedFilter.CreatedAfter + } + if f.Modified == "" { + f.Modified = parsedFilter.Modified + } + if f.ModifiedBefore == "" { + f.ModifiedBefore = parsedFilter.ModifiedBefore + } + if f.ModifiedAfter == "" { + f.ModifiedAfter = parsedFilter.ModifiedAfter + } + + if f.Match == "" { + f.Match = parsedFilter.Match + } else if parsedFilter.Match != "" { + f.Match = fmt.Sprintf("(%s) AND (%s)", f.Match, parsedFilter.Match) + } + + } else { + actualPaths = append(actualPaths, path) + } + } + + f.Path = actualPaths + return f, nil +} + // NewFinderOpts creates an instance of note.FinderOpts from a set of user flags. -func NewFinderOpts(zk *zk.Zk, filtering Filtering, sorting Sorting) (*note.FinderOpts, error) { +func NewFinderOpts(zk *zk.Zk, filtering Filtering) (*note.FinderOpts, error) { + filtering, err := filtering.ExpandNamedFilters(zk.Config.Filters, []string{}) + if err != nil { + return nil, err + } + opts := note.FinderOpts{} opts.Match = opt.NewNotEmptyString(filtering.Match) @@ -152,7 +244,7 @@ func NewFinderOpts(zk *zk.Zk, filtering Filtering, sorting Sorting) (*note.Finde opts.Interactive = filtering.Interactive - sorters, err := note.SortersFromStrings(sorting.Sort) + sorters, err := note.SortersFromStrings(filtering.Sort) if err != nil { return nil, err } diff --git a/cmd/finder_opts_test.go b/cmd/finder_opts_test.go new file mode 100644 index 0000000..929069d --- /dev/null +++ b/cmd/finder_opts_test.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "testing" + + "github.com/mickael-menu/zk/util/test/assert" +) + +func TestExpandNamedFiltersNone(t *testing.T) { + f := Filtering{ + Path: []string{"path1"}, + Limit: 10, + Interactive: true, + Match: "match query", + Exclude: []string{"excl-path1", "excl-path2"}, + Tag: []string{"tag1", "tag2"}, + Mention: []string{"mention1", "mention2"}, + MentionedBy: []string{"note1", "note2"}, + LinkTo: []string{"link1", "link2"}, + NoLinkTo: []string{"link3", "link4"}, + LinkedBy: []string{"linked1", "linked2"}, + NoLinkedBy: []string{"linked3", "linked4"}, + Related: []string{"related1", "related2"}, + MaxDistance: 2, + Created: "yesterday", + CreatedBefore: "two days ago", + CreatedAfter: "three days ago", + Modified: "tomorrow", + ModifiedBefore: "two days", + ModifiedAfter: "three days", + Sort: []string{"title", "created"}, + } + + res, err := f.ExpandNamedFilters( + map[string]string{ + "recents": "--created-after '2 weeks ago'", + "journal": "log --sort created", + }, + []string{}, + ) + + assert.Nil(t, err) + assert.Equal(t, res, f) +} + +// ExpandNamedFilters: list options are concatenated. +func TestExpandNamedFiltersJoinLists(t *testing.T) { + f := Filtering{ + Path: []string{"path1", "f1", "f2"}, + Exclude: []string{"excl-path1", "excl-path2"}, + Tag: []string{"tag1", "tag2"}, + Mention: []string{"mention1", "mention2"}, + MentionedBy: []string{"note1", "note2"}, + LinkTo: []string{"link1", "link2"}, + NoLinkTo: []string{"link3", "link4"}, + LinkedBy: []string{"linked1", "linked2"}, + NoLinkedBy: []string{"linked3", "linked4"}, + Related: []string{"related1", "related2"}, + Sort: []string{"title", "created"}, + } + + res, err := f.ExpandNamedFilters( + map[string]string{ + "f1": "path2 --exclude excl-path3 -x excl-path4 --tag tag3 -t tag4 --mention mention3,mention4 --mentioned-by note3", + "f2": "--link-to link5 --no-link-to link6 --linked-by linked5 --no-linked-by linked6 --related related3 --related related4 --sort random-", + }, + []string{}, + ) + + assert.Nil(t, err) + assert.Equal(t, res.Path, []string{"path1", "path2"}) + assert.Equal(t, res.Exclude, []string{"excl-path1", "excl-path2", "excl-path3", "excl-path4"}) + assert.Equal(t, res.Tag, []string{"tag1", "tag2", "tag3", "tag4"}) + assert.Equal(t, res.Mention, []string{"mention1", "mention2", "mention3", "mention4"}) + assert.Equal(t, res.MentionedBy, []string{"note1", "note2", "note3"}) + assert.Equal(t, res.LinkTo, []string{"link1", "link2", "link5"}) + assert.Equal(t, res.NoLinkTo, []string{"link3", "link4", "link6"}) + assert.Equal(t, res.LinkedBy, []string{"linked1", "linked2", "linked5"}) + assert.Equal(t, res.NoLinkedBy, []string{"linked3", "linked4", "linked6"}) + assert.Equal(t, res.Related, []string{"related1", "related2", "related3", "related4"}) + assert.Equal(t, res.Sort, []string{"title", "created", "random-"}) +} + +// ExpandNamedFilters: boolean options are computed with disjunction. +func TestExpandNamedFiltersJoinBools(t *testing.T) { + f := Filtering{ + Path: []string{"path1", "f1", "f2"}, + } + + res, err := f.ExpandNamedFilters( + map[string]string{ + "f1": "--interactive --orphan", + "f2": "--recursive", + }, + []string{}, + ) + + assert.Nil(t, err) + assert.True(t, res.Interactive) + assert.True(t, res.Orphan) + assert.True(t, res.Recursive) +} + +// ExpandNamedFilters: non-zero integer and non-empty string options take precedence over named filters. +func TestExpandNamedFiltersJoinLitterals(t *testing.T) { + f1 := Filtering{Path: []string{"f1", "f2"}} + res1, err := f1.ExpandNamedFilters( + map[string]string{ + "f1": "--limit 42 --created 'yesterday' --created-before '2 days ago' --created-after '3 days ago'", + "f2": "--max-distance 24 --modified 'tomorrow' --modified-before '2 days' --modified-after '3 days'", + }, + []string{}, + ) + assert.Nil(t, err) + assert.Equal(t, res1.Limit, 42) + assert.Equal(t, res1.MaxDistance, 24) + assert.Equal(t, res1.Created, "yesterday") + assert.Equal(t, res1.CreatedBefore, "2 days ago") + assert.Equal(t, res1.CreatedAfter, "3 days ago") + assert.Equal(t, res1.Modified, "tomorrow") + assert.Equal(t, res1.ModifiedBefore, "2 days") + assert.Equal(t, res1.ModifiedAfter, "3 days") + + f2 := Filtering{ + Path: []string{"f1", "f2"}, + Limit: 10, + MaxDistance: 20, + Created: "last week", + CreatedBefore: "two weeks ago", + CreatedAfter: "three weeks ago", + Modified: "next week", + ModifiedBefore: "two weeks", + ModifiedAfter: "three weeks", + } + res2, err := f2.ExpandNamedFilters( + map[string]string{ + "f1": "--limit 42 --created 'yesterday' --created-before '2 days ago' --created-after '3 days ago'", + "f2": "--max-distance 24 --modified 'tomorrow' --modified-before '2 days' --modified-after '3 days'", + }, + []string{}, + ) + + assert.Nil(t, err) + assert.Equal(t, res2.Limit, 10) + assert.Equal(t, res2.MaxDistance, 20) + assert.Equal(t, res2.Created, "last week") + assert.Equal(t, res2.CreatedBefore, "two weeks ago") + assert.Equal(t, res2.CreatedAfter, "three weeks ago") + assert.Equal(t, res2.Modified, "next week") + assert.Equal(t, res2.ModifiedBefore, "two weeks") + assert.Equal(t, res2.ModifiedAfter, "three weeks") +} + +// ExpandNamedFilters: Match option predicates are cumulated with AND. +func TestExpandNamedFiltersJoinMatch(t *testing.T) { + f := Filtering{ + Path: []string{"f1", "f2"}, + Match: "(chocolate OR caramel)", + } + + res, err := f.ExpandNamedFilters( + map[string]string{ + "f1": "--match banana", + "f2": "--match apple", + }, + []string{}, + ) + + assert.Nil(t, err) + assert.Equal(t, res.Match, "(((chocolate OR caramel)) AND (banana)) AND (apple)") +} + +func TestExpandNamedFiltersExpandsRecursively(t *testing.T) { + f := Filtering{ + Path: []string{"path1", "journal", "recents"}, + } + + res, err := f.ExpandNamedFilters( + map[string]string{ + "recents": "--created-after '2 weeks ago'", + "journal": "journal sort-created", + "sort-created": "--sort created", + }, + []string{}, + ) + + assert.Nil(t, err) + assert.Equal(t, res.Path, []string{"path1", "journal"}) + assert.Equal(t, res.CreatedAfter, "2 weeks ago") + assert.Equal(t, res.Sort, []string{"created"}) +} + +func TestExpandNamedFiltersReportsParsingError(t *testing.T) { + f := Filtering{Path: []string{"f1"}} + + _, err := f.ExpandNamedFilters( + map[string]string{ + "f1": "--test", + }, + []string{}, + ) + + assert.Err(t, err, "failed to expand named filter `f1`: unknown flag --test") +} diff --git a/cmd/list.go b/cmd/list.go index 2f7e01a..4f67ccb 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -19,9 +19,7 @@ type List struct { Delimiter0 bool "group:format short:0 name:delimiter0 help:\"Print notes delimited by ASCII NUL characters. This is useful when used in conjunction with `xargs -0`.\"" NoPager bool `group:format short:P help:"Do not pipe output into a pager."` Quiet bool `group:format short:q help:"Do not print the total number of notes found."` - Filtering - Sorting } func (cmd *List) Run(container *Container) error { @@ -34,7 +32,7 @@ func (cmd *List) Run(container *Container) error { return err } - opts, err := NewFinderOpts(zk, cmd.Filtering, cmd.Sorting) + opts, err := NewFinderOpts(zk, cmd.Filtering) if err != nil { return err } diff --git a/core/zk/config.go b/core/zk/config.go index a637932..a91cbd6 100644 --- a/core/zk/config.go +++ b/core/zk/config.go @@ -16,6 +16,7 @@ type Config struct { Groups map[string]GroupConfig Format FormatConfig Tool ToolConfig + Filters map[string]string Aliases map[string]string Extra map[string]string // Base directories for the relative template paths used in NoteConfig. @@ -45,6 +46,7 @@ func NewDefaultConfig() Config { MultiwordTags: false, }, }, + Filters: map[string]string{}, Aliases: map[string]string{}, Extra: map[string]string{}, TemplatesDirs: []string{}, @@ -256,6 +258,13 @@ func ParseConfig(content []byte, path string, parentConfig Config) (Config, erro config.Tool.FzfPreview = opt.NewStringWithPtr(tool.FzfPreview) } + // Filters + if tomlConf.Filters != nil { + for k, v := range tomlConf.Filters { + config.Filters[k] = v + } + } + // Aliases if tomlConf.Aliases != nil { for k, v := range tomlConf.Aliases { @@ -322,6 +331,7 @@ type tomlConfig struct { Format tomlFormatConfig Tool tomlToolConfig Extra map[string]string + Filters map[string]string `toml:"filter"` Aliases map[string]string `toml:"alias"` } diff --git a/core/zk/config_test.go b/core/zk/config_test.go index ff5a7ba..28ccbc5 100644 --- a/core/zk/config_test.go +++ b/core/zk/config_test.go @@ -42,6 +42,7 @@ func TestParseDefaultConfig(t *testing.T) { Pager: opt.NullString, FzfPreview: opt.NullString, }, + Filters: make(map[string]string), Aliases: make(map[string]string), Extra: make(map[string]string), TemplatesDirs: []string{".zk/templates"}, @@ -81,6 +82,10 @@ func TestParseComplete(t *testing.T) { hello = "world" salut = "le monde" + [filter] + recents = "--created-after '2 weeks ago'" + journal = "journal --sort created" + [alias] ls = "zk list $@" ed = "zk edit $@" @@ -194,6 +199,10 @@ func TestParseComplete(t *testing.T) { Pager: opt.NewString("less"), FzfPreview: opt.NewString("bat {1}"), }, + Filters: map[string]string{ + "recents": "--created-after '2 weeks ago'", + "journal": "journal --sort created", + }, Aliases: map[string]string{ "ls": "zk list $@", "ed": "zk edit $@", @@ -298,6 +307,7 @@ func TestParseMergesGroupConfig(t *testing.T) { MultiwordTags: false, }, }, + Filters: make(map[string]string), Aliases: make(map[string]string), Extra: map[string]string{ "hello": "world", diff --git a/core/zk/zk.go b/core/zk/zk.go index da17bcb..cbc907d 100644 --- a/core/zk/zk.go +++ b/core/zk/zk.go @@ -118,6 +118,18 @@ hashtags = true #fzf-preview = "bat -p --color always {-1}" +# NAMED FILTERS +# +# A named filter is a set of note filtering options used frequently together. +# +[filter] + +# Matches the notes created the last two weeks. For example: +# $ zk list recents --limit 15 +# $ zk edit recents --interactive +#recents = "--sort created- --created-after 'last two weeks'" + + # COMMAND ALIASES # # Aliases are user commands called with ` + "`" + `zk [] []` + "`" + `. diff --git a/docs/automation.md b/docs/automation.md index ee04a37..e6206b9 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -2,7 +2,7 @@ `zk` was designed with automation in mind and strive to be [a good Unix citizen](https://en.wikipedia.org/wiki/Unix_philosophy). As such, it offers a number of ways to interface with other programs: -* [write command aliases](config-alias.md) for repeated complex commands +* write [command aliases](config-alias.md) or [named filters](config-filter.md) for repeated complex commands * [call `zk` from other programs](external-call.md) * [send notes for processing by other programs](external-processing.md) * [create a note with initial content](note-creation.md) from a standard input pipe diff --git a/docs/config-alias.md b/docs/config-alias.md index 9c026a0..bb55a00 100644 --- a/docs/config-alias.md +++ b/docs/config-alias.md @@ -68,6 +68,8 @@ In this case, additional arguments do not necessarily make sense, so we omit the recent = "zk edit --sort created- --created-after 'last two weeks' --interactive" ``` +This kind of alias might be more useful as a [named filter](config-filter.md). + ### Edit the configuration file Here's a concrete example using environment variables, in particular `ZK_NOTEBOOK_DIR`. Note the double quotes around the path. diff --git a/docs/config-filter.md b/docs/config-filter.md new file mode 100644 index 0000000..9ec1a89 --- /dev/null +++ b/docs/config-filter.md @@ -0,0 +1,49 @@ +# Named filter + +A named filter is a set of [note filtering options](note-filtering.md) used frequently together, declared in the [configuration file](config.md). + +For example, if you use regularly the following command to list your most recent notes: + +```sh +$ zk list --sort created- --created-after "last two weeks" +``` + +You can create a new named filter in the configuration file to avoid repeating yourself. + +```toml +[filter] +recents = "--sort created- --created-after 'last two weeks'" +``` + +Then, you can use the name as an argument of `zk list`, with any additional option. + +```sh +$ zk list recents --limit 10 +``` + +Named filters are similar to [command aliases](config-alias.md), as they simplify frequent commands. However, named filters can be used with any command accepting filtering options. + +```sh +$ zk edit recents --interactive +``` + +## Filter named after a directory + +In filtering commands, named filters take precedence over path arguments. As a nice side effect, this means you can customize the default filtering options for a directory by naming a filter after it. + +For example, by default `zk` sorts notes by their titles. However, if you keep daily notes under a `journal/` directory, you may want to sort them by creation date instead. You can use the following named filter for this: + +``` +[filter] +journal = "--sort created journal" +``` + +Named filters cannot call themselves recursively, so by adding the `journal` argument to the filter, we are actually selecting the `journal/` directory. This means that the following commands are equivalent: + +```sh +# Without the filter +$ zk list --sort created journal + +# With the filter +$ zk list journal +``` diff --git a/docs/config.md b/docs/config.md index ca5dc5b..9b6718b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -10,6 +10,7 @@ Each [notebook](notebook.md) contains a configuration file used to customize you * [your default editor](tool-editor.md) * [your default pager](tool-pager.md) * [`fzf`](tool-fzf.md) +* `[filter]` declares your [named filters](config-filter.md) * `[alias]` holds your [command aliases](config-alias.md) ## Global configuration file @@ -88,6 +89,9 @@ pager = "less -FIRX" # Command used to preview a note during interactive fzf mode. fzf-preview = "bat -p --color always {-1}" +# NAMED FILTERS +[filter] +recents = "--sort created- --created-after 'last two weeks'" # COMMAND ALIASES [alias] diff --git a/docs/note-filtering.md b/docs/note-filtering.md index c23fbaf..f526cc0 100644 --- a/docs/note-filtering.md +++ b/docs/note-filtering.md @@ -1,6 +1,6 @@ # Searching and filtering notes -A few commands are built upon `zk`'s powerful note filtering capabilities, such as `edit` and `list`. They accept any option described here. +A few commands are built upon `zk`'s powerful note filtering capabilities, such as `edit` and `list`. They accept any option described here. You may also declare [named filters](config-filter.md) in the [configuration file](config.md) for the same set of options you use frequently. ## Filter by path diff --git a/util/strings/strings.go b/util/strings/strings.go index 5f620b5..fdac0f3 100644 --- a/util/strings/strings.go +++ b/util/strings/strings.go @@ -91,3 +91,13 @@ func RemoveDuplicates(strings []string) []string { return res } + +// InList returns whether the string is part of the given list of strings. +func InList(strings []string, s string) bool { + for _, c := range strings { + if c == s { + return true + } + } + return false +} diff --git a/util/strings/strings_test.go b/util/strings/strings_test.go index b40ee21..3cb6c7a 100644 --- a/util/strings/strings_test.go +++ b/util/strings/strings_test.go @@ -93,3 +93,16 @@ func TestRemoveDuplicates(t *testing.T) { test([]string{"Two", "One", "Two", "One"}, []string{"Two", "One"}) test([]string{"One", "Two", "OneTwo"}, []string{"One", "Two", "OneTwo"}) } + +func TestInList(t *testing.T) { + test := func(items []string, s string, expected bool) { + assert.Equal(t, InList(items, s), expected) + } + + test([]string{}, "", false) + test([]string{}, "none", false) + test([]string{"one"}, "none", false) + test([]string{"one"}, "one", true) + test([]string{"one", "two"}, "one", true) + test([]string{"one", "two"}, "three", false) +}