diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f97fa..6fd23be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ All notable changes to this project will be documented in this file. ### Added * Support for tags. - * Filter notes by their tags using `--tag "history, europe"`. To match notes associated with either tags, use a pipe `|` or `OR` (all caps), e.g. `--tag "inbox OR todo"`. + * Filter notes by their tags using `--tag "history, europe"`. + * To match notes associated with either tags, use a pipe `|` or `OR` (all caps), e.g. `--tag "inbox OR todo"`. + * If you want to exclude notes having a particular tag, prefix it with `-` or `NOT` (all caps), e.g. `--tag "NOT done"` * Many tag flavors are supported: `#hashtags`, `:colon:separated:tags:` and even Bear's [`#multi-word tags#`](https://blog.bear.app/2017/11/bear-tips-how-to-create-multi-word-tags/). If you prefer to use a YAML frontmatter, list your tags with the key `tags` or `keywords`. ### Changed diff --git a/adapter/sqlite/note_dao.go b/adapter/sqlite/note_dao.go index 34e1be6..0328a4e 100644 --- a/adapter/sqlite/note_dao.go +++ b/adapter/sqlite/note_dao.go @@ -442,11 +442,22 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) { break } separatorRegex := regexp.MustCompile(`(\ OR\ )|\|`) - for _, tags := range filter { - tags := separatorRegex.Split(tags, -1) + for _, tagsArg := range filter { + tags := separatorRegex.Split(tagsArg, -1) + negate := false globs := make([]string, 0) for _, tag := range tags { + tag = strings.TrimSpace(tag) + + if strings.HasPrefix(tag, "-") { + negate = true + tag = strings.TrimPrefix(tag, "-") + } else if strings.HasPrefix(tag, "NOT") { + negate = true + tag = strings.TrimPrefix(tag, "NOT") + } + tag = strings.TrimSpace(tag) if len(tag) == 0 { continue @@ -455,13 +466,25 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) { args = append(args, tag) } - whereExprs = append(whereExprs, fmt.Sprintf(`n.id IN ( + if len(globs) == 0 { + continue + } + if negate && len(globs) > 1 { + return nil, fmt.Errorf("cannot negate a tag in a OR group: %s", tagsArg) + } + + expr := "n.id" + if negate { + expr += " NOT" + } + expr += fmt.Sprintf(` IN ( SELECT note_id FROM notes_collections WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s)) )`, note.CollectionKindTag, strings.Join(globs, " OR "), - )) + ) + whereExprs = append(whereExprs, expr) } case note.ExcludePathFilter: diff --git a/adapter/sqlite/note_dao_test.go b/adapter/sqlite/note_dao_test.go index 2a0a006..c7fcd18 100644 --- a/adapter/sqlite/note_dao_test.go +++ b/adapter/sqlite/note_dao_test.go @@ -418,6 +418,9 @@ func TestNoteDAOFindTag(t *testing.T) { test([]string{"fiction | adventure | fantasy"}, []string{"ref/test/b.md", "f39c8.md", "log/2021-01-03.md"}) test([]string{"fiction | history", "adventure"}, []string{"ref/test/b.md", "log/2021-01-03.md"}) test([]string{"fiction", "unknown"}, []string{}) + test([]string{"-fiction"}, []string{"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md"}) + test([]string{"NOT fiction"}, []string{"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md"}) + test([]string{"NOTfiction"}, []string{"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md"}) } func TestNoteDAOFindMatch(t *testing.T) {