From 083c0dae736fb40e9eb3e4fe58a8adcc24bf4939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Sat, 17 Apr 2021 13:06:23 +0200 Subject: [PATCH] Add --exact-match option (#30) --- CHANGELOG.md | 5 +++ docs/note-filtering.md | 9 +++++ internal/adapter/sqlite/note_dao.go | 18 +++++++--- internal/adapter/sqlite/note_dao_test.go | 35 ++++++++++++++++--- .../adapter/sqlite/testdata/default/notes.yml | 4 +-- internal/adapter/sqlite/util.go | 14 ++++++++ internal/adapter/sqlite/util_test.go | 17 +++++++++ internal/cli/filtering.go | 3 ++ internal/cli/filtering_test.go | 3 +- internal/core/note_find.go | 2 ++ 10 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 internal/adapter/sqlite/util.go create mode 100644 internal/adapter/sqlite/util_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c6203..bdff922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +* Pair `--match` with `--exact-match` / `-e` to search for (case insensitive) exact occurrences in your notes. + * This can be useful when looking for terms including special characters, such as `[[name]]`. + ### Changed * The local configuration is not required anymore in a notebook's `.zk` directory. diff --git a/docs/note-filtering.md b/docs/note-filtering.md index f526cc0..e6d5e44 100644 --- a/docs/note-filtering.md +++ b/docs/note-filtering.md @@ -106,6 +106,15 @@ Prefixing a query with `^` will match notes whose title or body start with the f "title: ^journal" ``` +### Search for special characters + +If you need to find patterns containing special characters, such as an `email@addre.ss` or a `[[wiki-link]]`, use the `--exact-match` / `-e` option. The search will be case-insensitive. + +``` +$ zk list --exact-match --match "[[link]]" +$ zk list -em "[[link]]" +``` + ## Filter by tags You can filter your notes by their [tags](tags.md) using `--tags` (or `-t`). diff --git a/internal/adapter/sqlite/note_dao.go b/internal/adapter/sqlite/note_dao.go index a8a378b..df2c4d9 100644 --- a/internal/adapter/sqlite/note_dao.go +++ b/internal/adapter/sqlite/note_dao.go @@ -450,6 +450,9 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts core.NoteFindOpts) (core.NoteFind if opts.Mention == nil { return opts, nil } + if opts.ExactMatch { + return opts, fmt.Errorf("--exact-match and --mention cannot be used together") + } // Find the IDs for the mentioned paths. ids, err := d.findIdsByPathPrefixes(opts.Mention) @@ -575,11 +578,16 @@ func (d *NoteDAO) findRows(opts core.NoteFindOpts, minimal bool) (*sql.Rows, err } if !opts.Match.IsNull() { - snippetCol = `snippet(fts_match.notes_fts, 2, '', '', '…', 20)` - joinClauses = append(joinClauses, "JOIN notes_fts fts_match ON n.id = fts_match.rowid") - additionalOrderTerms = append(additionalOrderTerms, `bm25(fts_match.notes_fts, 1000.0, 500.0, 1.0)`) - whereExprs = append(whereExprs, "fts_match.notes_fts MATCH ?") - args = append(args, fts5.ConvertQuery(opts.Match.String())) + if opts.ExactMatch { + whereExprs = append(whereExprs, `n.raw_content LIKE '%' || ? || '%' ESCAPE '\'`) + args = append(args, escapeLikeTerm(opts.Match.String(), '\\')) + } else { + snippetCol = `snippet(fts_match.notes_fts, 2, '', '', '…', 20)` + joinClauses = append(joinClauses, "JOIN notes_fts fts_match ON n.id = fts_match.rowid") + additionalOrderTerms = append(additionalOrderTerms, `bm25(fts_match.notes_fts, 1000.0, 500.0, 1.0)`) + whereExprs = append(whereExprs, "fts_match.notes_fts MATCH ?") + args = append(args, fts5.ConvertQuery(opts.Match.String())) + } } if opts.IncludePaths != nil { diff --git a/internal/adapter/sqlite/note_dao_test.go b/internal/adapter/sqlite/note_dao_test.go index f422162..e80d475 100644 --- a/internal/adapter/sqlite/note_dao_test.go +++ b/internal/adapter/sqlite/note_dao_test.go @@ -489,7 +489,7 @@ func TestNoteDAOFindMatch(t *testing.T) { Title: "Daily note", Lead: "A daily note", Body: "A daily note\n\nWith lot of content", - RawContent: "# A daily note\nA daily note\n\nWith lot of content", + RawContent: "# Daily note\nA note\n\nWith lot of content", WordCount: 3, Links: []core.Link{}, Tags: []string{"fiction", "adventure"}, @@ -559,6 +559,33 @@ func TestNoteDAOFindMatchWithSort(t *testing.T) { ) } +func TestNoteDAOFindExactMatch(t *testing.T) { + test := func(match string, expected []string) { + testNoteDAOFindPaths(t, + core.NoteFindOpts{ + Match: opt.NewString(match), + ExactMatch: true, + }, + expected, + ) + } + + // Case insensitive + test("dailY NOTe", []string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"}) + // Special characters + test(`[exact% ch\ar_acters]`, []string{"ref/test/a.md"}) +} + +func TestNoteDAOFindExactMatchCannotBeUsedWithMention(t *testing.T) { + testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { + _, err := dao.Find(core.NoteFindOpts{ + ExactMatch: true, + Mention: []string{"mention"}, + }) + assert.Err(t, err, "--exact-match and --mention cannot be used together") + }) +} + func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) { testNoteDAOFindPaths(t, core.NoteFindOpts{ @@ -709,7 +736,7 @@ func TestNoteDAOFindMentionedBy(t *testing.T) { Title: "Daily note", Lead: "A daily note", Body: "A daily note\n\nWith lot of content", - RawContent: "# A daily note\nA daily note\n\nWith lot of content", + RawContent: "# Daily note\nA note\n\nWith lot of content", WordCount: 3, Links: []core.Link{}, Tags: []string{"fiction", "adventure"}, @@ -815,7 +842,7 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) { Title: "Another nested note", Lead: "It shall appear before b.md", Body: "It shall appear before b.md", - RawContent: "#Another nested note\nIt shall appear before b.md", + RawContent: "#Another nested note\nIt shall appear before b.md\nMatch [exact% ch\\ar_acters]", WordCount: 5, Links: []core.Link{}, Tags: []string{}, @@ -838,7 +865,7 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) { Title: "Daily note", Lead: "A daily note", Body: "A daily note\n\nWith lot of content", - RawContent: "# A daily note\nA daily note\n\nWith lot of content", + RawContent: "# Daily note\nA note\n\nWith lot of content", WordCount: 3, Links: []core.Link{}, Tags: []string{"fiction", "adventure"}, diff --git a/internal/adapter/sqlite/testdata/default/notes.yml b/internal/adapter/sqlite/testdata/default/notes.yml index 187bc29..3cfb5cf 100644 --- a/internal/adapter/sqlite/testdata/default/notes.yml +++ b/internal/adapter/sqlite/testdata/default/notes.yml @@ -4,7 +4,7 @@ title: "Daily note" lead: "A daily note" body: "A daily note\n\nWith lot of content" - raw_content: "# A daily note\nA daily note\n\nWith lot of content" + raw_content: "# Daily note\nA note\n\nWith lot of content" word_count: 3 checksum: "qwfpgj" created: "2020-11-22T16:27:45Z" @@ -69,7 +69,7 @@ title: "Another nested note" lead: "It shall appear before b.md" body: "It shall appear before b.md" - raw_content: "#Another nested note\nIt shall appear before b.md" + raw_content: "#Another nested note\nIt shall appear before b.md\nMatch [exact% ch\\ar_acters]" word_count: 5 checksum: "iecywst" created: "2019-11-20T20:32:56Z" diff --git a/internal/adapter/sqlite/util.go b/internal/adapter/sqlite/util.go new file mode 100644 index 0000000..cd32acd --- /dev/null +++ b/internal/adapter/sqlite/util.go @@ -0,0 +1,14 @@ +package sqlite + +import "strings" + +// escapeLikeTerm returns the given term after escaping any LIKE-significant +// characters with the given escapeChar. +// This is meant to be used with the ESCAPE keyword: +// https://www.sqlite.org/lang_expr.html +func escapeLikeTerm(term string, escapeChar rune) string { + escape := func(term string, char string) string { + return strings.ReplaceAll(term, char, string(escapeChar)+char) + } + return escape(escape(escape(term, string(escapeChar)), "%"), "_") +} diff --git a/internal/adapter/sqlite/util_test.go b/internal/adapter/sqlite/util_test.go new file mode 100644 index 0000000..d36273a --- /dev/null +++ b/internal/adapter/sqlite/util_test.go @@ -0,0 +1,17 @@ +package sqlite + +import ( + "testing" + + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +func TestEscapeLikeTerm(t *testing.T) { + test := func(term string, escapeChar rune, expected string) { + assert.Equal(t, escapeLikeTerm(term, escapeChar), expected) + } + + test("foo bar", '@', "foo bar") + test("foo%bar_with@", '@', "foo@%bar@_with@@") + test(`foo%bar_with\`, '\\', `foo\%bar\_with\\`) +} diff --git a/internal/cli/filtering.go b/internal/cli/filtering.go index 675f33b..c6fc7f0 100644 --- a/internal/cli/filtering.go +++ b/internal/cli/filtering.go @@ -21,6 +21,7 @@ type Filtering struct { Interactive bool `group:filter short:i help:"Select notes interactively with fzf."` Limit int `group:filter short:n placeholder:COUNT help:"Limit the number of notes found."` Match string `group:filter short:m placeholder:QUERY help:"Terms to search for in the notes."` + ExactMatch bool `group:filter short:e help:"Search for exact occurrences of the --match argument (case insensitive)."` 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."` @@ -84,6 +85,7 @@ func (f Filtering) ExpandNamedFilters(filters map[string]string, expandedFilters f.Related = append(f.Related, parsedFilter.Related...) f.Sort = append(f.Sort, parsedFilter.Sort...) + f.ExactMatch = f.ExactMatch || parsedFilter.ExactMatch f.Interactive = f.Interactive || parsedFilter.Interactive f.Orphan = f.Orphan || parsedFilter.Orphan f.Recursive = f.Recursive || parsedFilter.Recursive @@ -138,6 +140,7 @@ func (f Filtering) NewNoteFindOpts(notebook *core.Notebook) (core.NoteFindOpts, } opts.Match = opt.NewNotEmptyString(f.Match) + opts.ExactMatch = f.ExactMatch if paths, ok := relPaths(notebook, f.Path); ok { opts.IncludePaths = paths diff --git a/internal/cli/filtering_test.go b/internal/cli/filtering_test.go index d9c703e..455048d 100644 --- a/internal/cli/filtering_test.go +++ b/internal/cli/filtering_test.go @@ -89,13 +89,14 @@ func TestExpandNamedFiltersJoinBools(t *testing.T) { res, err := f.ExpandNamedFilters( map[string]string{ - "f1": "--interactive --orphan", + "f1": "--exact-match --interactive --orphan", "f2": "--recursive", }, []string{}, ) assert.Nil(t, err) + assert.True(t, res.ExactMatch) assert.True(t, res.Interactive) assert.True(t, res.Orphan) assert.True(t, res.Recursive) diff --git a/internal/core/note_find.go b/internal/core/note_find.go index 78e93a1..df767e6 100644 --- a/internal/core/note_find.go +++ b/internal/core/note_find.go @@ -13,6 +13,8 @@ import ( type NoteFindOpts struct { // Filter used to match the notes with FTS predicates. Match opt.String + // Search for exact occurrences of the Match string. + ExactMatch bool // Filter by note paths. IncludePaths []string // Filter excluding notes at the given paths.