Add --exact-match option (#30)

pull/35/head
Mickaël Menu 3 years ago committed by GitHub
parent 6ba92a03b7
commit 083c0dae73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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.

@ -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`).

@ -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, '<zk:match>', '</zk:match>', '…', 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, '<zk:match>', '</zk:match>', '…', 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 {

@ -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"},

@ -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"

@ -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)), "%"), "_")
}

@ -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\\`)
}

@ -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

@ -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)

@ -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.

Loading…
Cancel
Save