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.