Skip to content

Commit

Permalink
Add --exact-match option (#30)
Browse files Browse the repository at this point in the history
  • Loading branch information
mickael-menu authored Apr 17, 2021
1 parent 6ba92a0 commit 083c0da
Show file tree
Hide file tree
Showing 10 changed files with 98 additions and 12 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions docs/note-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 protected]` 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`).
Expand Down
18 changes: 13 additions & 5 deletions internal/adapter/sqlite/note_dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 31 additions & 4 deletions internal/adapter/sqlite/note_dao_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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{},
Expand All @@ -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"},
Expand Down
4 changes: 2 additions & 2 deletions internal/adapter/sqlite/testdata/default/notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions internal/adapter/sqlite/util.go
Original file line number Diff line number Diff line change
@@ -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)), "%"), "_")
}
17 changes: 17 additions & 0 deletions internal/adapter/sqlite/util_test.go
Original file line number Diff line number Diff line change
@@ -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\\`)
}
3 changes: 3 additions & 0 deletions internal/cli/filtering.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/filtering_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/core/note_find.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 083c0da

Please sign in to comment.