Filter notes by tags (#6)

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"`.
pull/7/head
Mickaël Menu 3 years ago committed by GitHub
parent 7dc3120d3a
commit 08cc4a3c3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,9 +7,9 @@ All notable changes to this project will be documented in this file.
### Added ### Added
* Support for tags. * Support for tags.
* 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 keys `tags` or `keywords`. * 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"`.
* 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 ### Changed
* Multiple `--extra` variables are now separated by `,` instead of `;`. * Multiple `--extra` variables are now separated by `,` instead of `;`.

@ -22,11 +22,7 @@ What `zk` is not:
* a note editor * a note editor
* a tool to serve your notes on the web for this, you may be interested in [Neuron](docs/neuron.md) or [Gollum](https://github.com/gollum/gollum). * a tool to serve your notes on the web for this, you may be interested in [Neuron](docs/neuron.md) or [Gollum](https://github.com/gollum/gollum).
## Roadmap [See the changelog](CHANGELOG.md) for the list of upcoming features waiting to be released.
* [ ] Tags
* [ ] Link relations
* [ ] Extended YAML front matter support
## Install ## Install

@ -15,6 +15,7 @@ import (
func Init(lang string, supportsUTF8 bool, logger util.Logger, styler style.Styler) { func Init(lang string, supportsUTF8 bool, logger util.Logger, styler style.Styler) {
helpers.RegisterConcat() helpers.RegisterConcat()
helpers.RegisterDate(logger) helpers.RegisterDate(logger)
helpers.RegisterJoin()
helpers.RegisterList(supportsUTF8) helpers.RegisterList(supportsUTF8)
helpers.RegisterPrepend(logger) helpers.RegisterPrepend(logger)
helpers.RegisterShell(logger) helpers.RegisterShell(logger)

@ -94,6 +94,18 @@ func TestConcatHelper(t *testing.T) {
testString(t, "{{concat '> ' 'A quote'}}", nil, "> A quote") testString(t, "{{concat '> ' 'A quote'}}", nil, "> A quote")
} }
func TestJoinHelper(t *testing.T) {
test := func(items []string, expected string) {
context := map[string]interface{}{"items": items}
testString(t, "{{join items '-'}}", context, expected)
}
test([]string{}, "")
test([]string{"Item 1"}, "Item 1")
test([]string{"Item 1", "Item 2"}, "Item 1-Item 2")
test([]string{"Item 1", "Item 2", "Item 3"}, "Item 1-Item 2-Item 3")
}
func TestPrependHelper(t *testing.T) { func TestPrependHelper(t *testing.T) {
// inline // inline
testString(t, "{{prepend '> ' 'A quote'}}", nil, "> A quote") testString(t, "{{prepend '> ' 'A quote'}}", nil, "> A quote")

@ -0,0 +1,17 @@
package helpers
import (
"strings"
"github.com/aymerick/raymond"
)
// RegisterJoin registers a {{join}} template helper which concatenates list
// items with the given separator.
//
// {{join list ', '}} -> item1, item2, item3
func RegisterJoin() {
raymond.RegisterHelper("join", func(list []string, delimiter string) string {
return strings.Join(list, delimiter)
})
}

@ -156,6 +156,7 @@ func (p *hashtagParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont
} }
} }
tag = strings.TrimSpace(tag)
if len(tag) == 0 || !isValidHashTag(tag) { if len(tag) == 0 || !isValidHashTag(tag) {
return nil return nil
} }
@ -220,6 +221,7 @@ func (p *colontagParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
escaping = true escaping = true
} else if char == ':' { } else if char == ':' {
tag = strings.TrimSpace(tag)
if len(tag) == 0 { if len(tag) == 0 {
break break
} }

@ -166,8 +166,18 @@ func parseTags(frontmatter frontmatter, root ast.Node, source []byte) ([]string,
findFMTags := func(key string) []string { findFMTags := func(key string) []string {
if tags, ok := frontmatter.getStrings(key); ok { if tags, ok := frontmatter.getStrings(key); ok {
return tags return tags
} else if tags := frontmatter.getString(key); !tags.IsNull() { } else if tags := frontmatter.getString(key); !tags.IsNull() {
return strings.Fields(tags.Unwrap()) // Parse a space-separated string list
res := []string{}
for _, s := range strings.Fields(tags.Unwrap()) {
s = strings.TrimSpace(s)
if len(s) > 0 {
res = append(res, s)
}
}
return res
} else { } else {
return []string{} return []string{}
} }
@ -304,11 +314,14 @@ func (m frontmatter) getStrings(keys ...string) ([]string, bool) {
key = strings.ToLower(key) key = strings.ToLower(key)
if val, ok := m.values[key]; ok { if val, ok := m.values[key]; ok {
if val, ok := val.([]interface{}); ok { if val, ok := val.([]interface{}); ok {
strings := []string{} strs := []string{}
for _, v := range val { for _, v := range val {
strings = append(strings, fmt.Sprint(v)) s := strings.TrimSpace(fmt.Sprint(v))
if len(s) > 0 {
strs = append(strs, s)
}
} }
return strings, true return strs, true
} }
} }
} }

@ -217,6 +217,8 @@ func TestParseWordtags(t *testing.T) {
test("#a/@'~-_$%&+=: end", []string{"a/@'~-_$%&+=:"}) test("#a/@'~-_$%&+=: end", []string{"a/@'~-_$%&+=:"})
// Escape punctuation and space // Escape punctuation and space
test(`#an\ \\espaced\ tag\!`, []string{`an \espaced tag!`}) test(`#an\ \\espaced\ tag\!`, []string{`an \espaced tag!`})
// Leading and trailing spaces are trimmed
test(`#\ \ tag\ \ end`, []string{`tag`})
// Hashtags containing only numbers and dots are invalid // Hashtags containing only numbers and dots are invalid
test("#123, #1.2.3", []string{}) test("#123, #1.2.3", []string{})
// Must not be preceded by a hash or any other valid hashtag character // Must not be preceded by a hash or any other valid hashtag character
@ -265,6 +267,8 @@ func TestParseColontags(t *testing.T) {
test(":#a/@'~-_$%&+=: end", []string{"#a/@'~-_$%&+="}) test(":#a/@'~-_$%&+=: end", []string{"#a/@'~-_$%&+="})
// Escape punctuation and space // Escape punctuation and space
test(`:an\ \\espaced\ tag\!:`, []string{`an \espaced tag!`}) test(`:an\ \\espaced\ tag\!:`, []string{`an \espaced tag!`})
// Leading and trailing spaces are trimmed
test(`:\ \ tag\ \ :`, []string{`tag`})
// A colontag containing only numbers is valid // A colontag containing only numbers is valid
test(":123:1.2.3:", []string{"123"}) test(":123:1.2.3:", []string{"123"})
// Must not be preceded by a : or any other valid colontag character // Must not be preceded by a : or any other valid colontag character
@ -308,9 +312,9 @@ Body
`, []string{"keyword1", "keyword 2"}) `, []string{"keyword1", "keyword 2"})
test(`--- test(`---
tags: [tag1, tag 2] tags: [tag1, " tag 2 "]
keywords: keywords:
- keyword1 - keyword1
- keyword 2 - keyword 2
--- ---
@ -319,7 +323,7 @@ Body
// When a string, parse space-separated tags. // When a string, parse space-separated tags.
test(`--- test(`---
Tags: "tag1 #tag-2" Tags: "tag1 #tag-2"
Keywords: kw1 kw2 kw3 Keywords: kw1 kw2 kw3
--- ---

@ -26,7 +26,7 @@ func TestCollectionDAOFindOrCreate(t *testing.T) {
// Creates when not found // Creates when not found
sql := "SELECT id FROM collections WHERE kind = ? AND name = ?" sql := "SELECT id FROM collections WHERE kind = ? AND name = ?"
assertNotExist(t, tx, sql, "unknown", "created") assertNotExist(t, tx, sql, "unknown", "created")
id, err = dao.FindOrCreate("unknown", "created") _, err = dao.FindOrCreate("unknown", "created")
assert.Nil(t, err) assert.Nil(t, err)
assertExist(t, tx, sql, "unknown", "created") assertExist(t, tx, sql, "unknown", "created")
}) })

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util/errors" "github.com/mickael-menu/zk/util/errors"
) )
@ -137,6 +138,14 @@ func (db *DB) Migrate() error {
)`, )`,
`CREATE INDEX IF NOT EXISTS index_notes_collections ON notes_collections (note_id, collection_id)`, `CREATE INDEX IF NOT EXISTS index_notes_collections ON notes_collections (note_id, collection_id)`,
// View of notes with their associated metadata (e.g. tags), for simpler queries.
`CREATE VIEW notes_with_metadata AS
SELECT n.*, GROUP_CONCAT(c.name, '` + "\x01" + `') AS tags
FROM notes n
LEFT JOIN notes_collections nc ON nc.note_id = n.id
LEFT JOIN collections c ON nc.collection_id = c.id AND c.kind = '` + string(note.CollectionKindTag) + `'
GROUP BY n.id`,
`PRAGMA user_version = 2`, `PRAGMA user_version = 2`,
}) })

@ -10,3 +10,6 @@
- id: 4 - id: 4
kind: "tag" kind: "tag"
name: "fantasy" name: "fantasy"
- id: 5
kind: "tag"
name: "history"

@ -1,9 +1,18 @@
- id: 1 - id: 1
note_id: 1 note_id: 1 # log/2021-01-03.md
collection_id: 1 collection_id: 1 # tag:fiction
- id: 2 - id: 2
note_id: 1 note_id: 1 # log/2021-01-03.md
collection_id: 2 collection_id: 2 # tag:adventure
- id: 3 - id: 3
note_id: 2 note_id: 2 # log/2021-01-04.md
collection_id: 3 collection_id: 3 # genre:fiction
- id: 4
note_id: 5 # ref/test/b.md
collection_id: 2 # tag:adventure
- id: 5
note_id: 4 # f39c8.md
collection_id: 4 # tag:fantasy
- id: 6
note_id: 5 # ref/test/b.md
collection_id: 5 # tag:adventure

@ -3,6 +3,7 @@ package sqlite
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -293,25 +294,19 @@ func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
var ( var (
id, wordCount int id, wordCount int
title, lead, body, rawContent string title, lead, body, rawContent string
nullableSnippets sql.NullString snippets, tags sql.NullString
path, checksum string path, checksum string
created, modified time.Time created, modified time.Time
) )
err := rows.Scan(&id, &path, &title, &lead, &body, &rawContent, &wordCount, &created, &modified, &checksum, &nullableSnippets) err := rows.Scan(&id, &path, &title, &lead, &body, &rawContent, &wordCount, &created, &modified, &checksum, &tags, &snippets)
if err != nil { if err != nil {
d.logger.Err(err) d.logger.Err(err)
continue continue
} }
snippets := make([]string, 0)
if nullableSnippets.Valid && nullableSnippets.String != "" {
snippets = strings.Split(nullableSnippets.String, "\x01")
snippets = strutil.RemoveDuplicates(snippets)
}
matches = append(matches, note.Match{ matches = append(matches, note.Match{
Snippets: snippets, Snippets: parseListFromNullString(snippets),
Metadata: note.Metadata{ Metadata: note.Metadata{
Path: path, Path: path,
Title: title, Title: title,
@ -319,6 +314,8 @@ func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
Body: body, Body: body,
RawContent: rawContent, RawContent: rawContent,
WordCount: wordCount, WordCount: wordCount,
Links: []note.Link{},
Tags: parseListFromNullString(tags),
Created: created, Created: created,
Modified: modified, Modified: modified,
Checksum: checksum, Checksum: checksum,
@ -329,12 +326,22 @@ func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
return matches, nil return matches, nil
} }
// parseListFromNullString splits a 0-separated string.
func parseListFromNullString(str sql.NullString) []string {
list := []string{}
if str.Valid && str.String != "" {
list = strings.Split(str.String, "\x01")
list = strutil.RemoveDuplicates(list)
}
return list
}
func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) { func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
snippetCol := `n.lead` snippetCol := `n.lead`
joinClauses := make([]string, 0) joinClauses := []string{}
whereExprs := make([]string, 0) whereExprs := []string{}
additionalOrderTerms := make([]string, 0) additionalOrderTerms := []string{}
args := make([]interface{}, 0) args := []interface{}{}
groupBy := "" groupBy := ""
transitiveClosure := false transitiveClosure := false
@ -359,7 +366,7 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
if !negate { if !negate {
if direction != 0 { if direction != 0 {
snippetCol = "GROUP_CONCAT(REPLACE(l.snippet, l.title, '<zk:match>' || l.title || '</zk:match>'), '\x01') AS snippet" snippetCol = "GROUP_CONCAT(REPLACE(l.snippet, l.title, '<zk:match>' || l.title || '</zk:match>'), '\x01')"
} }
joinOns := make([]string, 0) joinOns := make([]string, 0)
@ -413,7 +420,7 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
switch filter := filter.(type) { switch filter := filter.(type) {
case note.MatchFilter: case note.MatchFilter:
snippetCol = `snippet(notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20) as snippet` snippetCol = `snippet(notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20)`
joinClauses = append(joinClauses, "JOIN notes_fts ON n.id = notes_fts.rowid") joinClauses = append(joinClauses, "JOIN notes_fts ON n.id = notes_fts.rowid")
additionalOrderTerms = append(additionalOrderTerms, `bm25(notes_fts, 1000.0, 500.0, 1.0)`) additionalOrderTerms = append(additionalOrderTerms, `bm25(notes_fts, 1000.0, 500.0, 1.0)`)
whereExprs = append(whereExprs, "notes_fts MATCH ?") whereExprs = append(whereExprs, "notes_fts MATCH ?")
@ -430,6 +437,33 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
} }
whereExprs = append(whereExprs, strings.Join(regexes, " OR ")) whereExprs = append(whereExprs, strings.Join(regexes, " OR "))
case note.TagFilter:
if len(filter) == 0 {
break
}
separatorRegex := regexp.MustCompile(`(\ OR\ )|\|`)
for _, tags := range filter {
tags := separatorRegex.Split(tags, -1)
globs := make([]string, 0)
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if len(tag) == 0 {
continue
}
globs = append(globs, "t.name GLOB ?")
args = append(args, tag)
}
whereExprs = append(whereExprs, fmt.Sprintf(`n.id 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 "),
))
}
case note.ExcludePathFilter: case note.ExcludePathFilter:
if len(filter) == 0 { if len(filter) == 0 {
break break
@ -504,14 +538,14 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
query += `WITH RECURSIVE transitive_closure(source_id, target_id, title, snippet, distance, path) AS ( query += `WITH RECURSIVE transitive_closure(source_id, target_id, title, snippet, distance, path) AS (
SELECT source_id, target_id, title, snippet, SELECT source_id, target_id, title, snippet,
1 AS distance, 1 AS distance,
'.' || source_id || '.' || target_id || '.' AS path '.' || source_id || '.' || target_id || '.' AS path
FROM links FROM links
UNION ALL UNION ALL
SELECT tc.source_id, l.target_id, l.title, l.snippet, SELECT tc.source_id, l.target_id, l.title, l.snippet,
tc.distance + 1, tc.distance + 1,
tc.path || l.target_id || '.' AS path tc.path || l.target_id || '.' AS path
FROM links AS l FROM links AS l
JOIN transitive_closure AS tc JOIN transitive_closure AS tc
@ -528,9 +562,9 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
query += "\n)\n" query += "\n)\n"
} }
query += "SELECT n.id, n.path, n.title, n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.checksum, " + snippetCol + "\n" query += fmt.Sprintf("SELECT n.id, n.path, n.title, n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.checksum, n.tags, %s AS snippet\n", snippetCol)
query += "FROM notes n\n" query += "FROM notes_with_metadata n\n"
for _, clause := range joinClauses { for _, clause := range joinClauses {
query += clause + "\n" query += clause + "\n"

@ -402,6 +402,24 @@ func TestNoteDAOFindLimit(t *testing.T) {
}) })
} }
func TestNoteDAOFindTag(t *testing.T) {
test := func(tags []string, expectedPaths []string) {
testNoteDAOFindPaths(t, note.FinderOpts{
Filters: []note.Filter{note.TagFilter(tags)},
}, expectedPaths)
}
test([]string{"fiction"}, []string{"log/2021-01-03.md"})
test([]string{" adventure "}, []string{"ref/test/b.md", "log/2021-01-03.md"})
test([]string{"fiction", "adventure"}, []string{"log/2021-01-03.md"})
test([]string{"fiction|fantasy"}, []string{"f39c8.md", "log/2021-01-03.md"})
test([]string{"fiction | fantasy"}, []string{"f39c8.md", "log/2021-01-03.md"})
test([]string{"fiction OR fantasy"}, []string{"f39c8.md", "log/2021-01-03.md"})
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{})
}
func TestNoteDAOFindMatch(t *testing.T) { func TestNoteDAOFindMatch(t *testing.T) {
testNoteDAOFind(t, testNoteDAOFind(t,
note.FinderOpts{ note.FinderOpts{
@ -416,6 +434,8 @@ func TestNoteDAOFindMatch(t *testing.T) {
Body: "Index of the Zettelkasten", Body: "Index of the Zettelkasten",
RawContent: "# Index\nIndex of the Zettelkasten", RawContent: "# Index\nIndex of the Zettelkasten",
WordCount: 4, WordCount: 4,
Links: []note.Link{},
Tags: []string{},
Created: time.Date(2019, 12, 4, 11, 59, 11, 0, time.UTC), Created: time.Date(2019, 12, 4, 11, 59, 11, 0, time.UTC),
Modified: time.Date(2019, 12, 4, 12, 17, 21, 0, time.UTC), Modified: time.Date(2019, 12, 4, 12, 17, 21, 0, time.UTC),
Checksum: "iaefhv", Checksum: "iaefhv",
@ -430,6 +450,8 @@ func TestNoteDAOFindMatch(t *testing.T) {
Body: "A third daily note", Body: "A third daily note",
RawContent: "# A third daily note", RawContent: "# A third daily note",
WordCount: 4, WordCount: 4,
Links: []note.Link{},
Tags: []string{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC), Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
Modified: time.Date(2020, 11, 10, 8, 20, 18, 0, time.UTC), Modified: time.Date(2020, 11, 10, 8, 20, 18, 0, time.UTC),
Checksum: "earkte", Checksum: "earkte",
@ -444,6 +466,8 @@ func TestNoteDAOFindMatch(t *testing.T) {
Body: "A second daily note", Body: "A second daily note",
RawContent: "# A second daily note", RawContent: "# A second daily note",
WordCount: 4, WordCount: 4,
Links: []note.Link{},
Tags: []string{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC), Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
Modified: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC), Modified: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
Checksum: "arstde", Checksum: "arstde",
@ -458,6 +482,8 @@ func TestNoteDAOFindMatch(t *testing.T) {
Body: "A daily note\n\nWith lot of content", Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content", RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3, WordCount: 3,
Links: []note.Link{},
Tags: []string{"fiction", "adventure"},
Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Checksum: "qwfpgj", Checksum: "qwfpgj",
@ -602,7 +628,8 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
Body: "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",
WordCount: 5, WordCount: 5,
Links: nil, Links: []note.Link{},
Tags: []string{},
Created: time.Date(2019, 11, 20, 20, 32, 56, 0, time.UTC), Created: time.Date(2019, 11, 20, 20, 32, 56, 0, time.UTC),
Modified: time.Date(2019, 11, 20, 20, 34, 6, 0, time.UTC), Modified: time.Date(2019, 11, 20, 20, 34, 6, 0, time.UTC),
Checksum: "iecywst", Checksum: "iecywst",
@ -620,7 +647,8 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
Body: "A daily note\n\nWith lot of content", Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content", RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3, WordCount: 3,
Links: nil, Links: []note.Link{},
Tags: []string{"fiction", "adventure"},
Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Checksum: "qwfpgj", Checksum: "qwfpgj",

@ -17,6 +17,7 @@ type Filtering struct {
Limit int `group:filter short:n placeholder:COUNT help:"Limit the number of notes found."` 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."` Match string `group:filter short:m placeholder:QUERY help:"Terms to search for in the notes."`
Exclude []string `group:filter short:x placeholder:PATH help:"Ignore notes matching the given path, including its descendants."` 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."`
Orphan bool `group:filter help:"Find notes which are not linked by any other note." xor:link` Orphan bool `group:filter help:"Find notes which are not linked by any other note." xor:link`
LinkedBy []string `group:filter short:l placeholder:PATH help:"Find notes which are linked by the given ones." xor:link` LinkedBy []string `group:filter short:l placeholder:PATH help:"Find notes which are linked by the given ones." xor:link`
LinkingTo []string `group:filter short:L placeholder:PATH help:"Find notes which are linking to the given ones." xor:link` LinkingTo []string `group:filter short:L placeholder:PATH help:"Find notes which are linking to the given ones." xor:link`
@ -53,6 +54,10 @@ func NewFinderOpts(zk *zk.Zk, filtering Filtering, sorting Sorting) (*note.Finde
filters = append(filters, note.ExcludePathFilter(excludePaths)) filters = append(filters, note.ExcludePathFilter(excludePaths))
} }
if len(filtering.Tag) > 0 {
filters = append(filters, note.TagFilter(filtering.Tag))
}
if filtering.Match != "" { if filtering.Match != "" {
filters = append(filters, note.MatchFilter(filtering.Match)) filters = append(filters, note.MatchFilter(filtering.Match))
} }

@ -44,6 +44,9 @@ type PathFilter []string
// ExcludePathFilter is a note filter using path globs to exclude notes from the list. // ExcludePathFilter is a note filter using path globs to exclude notes from the list.
type ExcludePathFilter []string type ExcludePathFilter []string
// TagFilter is a note filter using tag globs found in the notes.
type TagFilter []string
// LinkedByFilter is a note filter used to select notes being linked by another one. // LinkedByFilter is a note filter used to select notes being linked by another one.
type LinkedByFilter struct { type LinkedByFilter struct {
Paths []string Paths []string
@ -80,6 +83,7 @@ type InteractiveFilter bool
func (f MatchFilter) sealed() {} func (f MatchFilter) sealed() {}
func (f PathFilter) sealed() {} func (f PathFilter) sealed() {}
func (f ExcludePathFilter) sealed() {} func (f ExcludePathFilter) sealed() {}
func (f TagFilter) sealed() {}
func (f LinkedByFilter) sealed() {} func (f LinkedByFilter) sealed() {}
func (f LinkingToFilter) sealed() {} func (f LinkingToFilter) sealed() {}
func (f RelatedFilter) sealed() {} func (f RelatedFilter) sealed() {}

@ -79,6 +79,7 @@ Modified: {{date created "short"}}
"full": `{{style "title" title}} {{style "path" path}} "full": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
Modified: {{date created "short"}} Modified: {{date created "short"}}
Tags: {{join tags ", "}}
{{prepend " " body}} {{prepend " " body}}
`, `,
@ -104,6 +105,7 @@ func (f *Formatter) Format(match Match) (string, error) {
Lead: match.Lead, Lead: match.Lead,
Body: match.Body, Body: match.Body,
Snippets: snippets, Snippets: snippets,
Tags: match.Tags,
RawContent: match.RawContent, RawContent: match.RawContent,
WordCount: match.WordCount, WordCount: match.WordCount,
Created: match.Created, Created: match.Created,
@ -120,6 +122,7 @@ type formatRenderContext struct {
Snippets []string Snippets []string
RawContent string `handlebars:"raw-content"` RawContent string `handlebars:"raw-content"`
WordCount int `handlebars:"word-count"` WordCount int `handlebars:"word-count"`
Tags []string
Created time.Time Created time.Time
Modified time.Time Modified time.Time
Checksum string Checksum string

@ -55,6 +55,7 @@ Modified: {{date created "short"}}
test("full", `{{style "title" title}} {{style "path" path}} test("full", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}} Created: {{date created "short"}}
Modified: {{date created "short"}} Modified: {{date created "short"}}
Tags: {{join tags ", "}}
{{prepend " " body}} {{prepend " " body}}
`) `)

@ -56,7 +56,7 @@ type GroupConfig struct {
Extra map[string]string Extra map[string]string
} }
// ConfigOverrides holds user configuration overriden values, for example fed // ConfigOverrides holds user configuration overridden values, for example fed
// from CLI flags. // from CLI flags.
type ConfigOverrides struct { type ConfigOverrides struct {
Group opt.String Group opt.String
@ -79,7 +79,7 @@ func (c GroupConfig) Clone() GroupConfig {
} }
// Override modifies the GroupConfig receiver by updating the properties // Override modifies the GroupConfig receiver by updating the properties
// overriden in ConfigOverrides. // overridden in ConfigOverrides.
func (c *GroupConfig) Override(overrides ConfigOverrides) { func (c *GroupConfig) Override(overrides ConfigOverrides) {
if !overrides.BodyTemplatePath.IsNull() { if !overrides.BodyTemplatePath.IsNull() {
c.Note.BodyTemplatePath = overrides.BodyTemplatePath c.Note.BodyTemplatePath = overrides.BodyTemplatePath

@ -445,9 +445,9 @@ func TestGroupConfigOverride(t *testing.T) {
// Some overrides // Some overrides
sut.Override(ConfigOverrides{ sut.Override(ConfigOverrides{
BodyTemplatePath: opt.NewString("overriden-template"), BodyTemplatePath: opt.NewString("overridden-template"),
Extra: map[string]string{ Extra: map[string]string{
"hello": "overriden", "hello": "overridden",
"additional": "value", "additional": "value",
}, },
}) })
@ -455,7 +455,7 @@ func TestGroupConfigOverride(t *testing.T) {
Paths: []string{"path"}, Paths: []string{"path"},
Note: NoteConfig{ Note: NoteConfig{
FilenameTemplate: "filename", FilenameTemplate: "filename",
BodyTemplatePath: opt.NewString("overriden-template"), BodyTemplatePath: opt.NewString("overridden-template"),
IDOptions: IDOptions{ IDOptions: IDOptions{
Length: 4, Length: 4,
Charset: CharsetLetters, Charset: CharsetLetters,
@ -463,7 +463,7 @@ func TestGroupConfigOverride(t *testing.T) {
}, },
}, },
Extra: map[string]string{ Extra: map[string]string{
"hello": "overriden", "hello": "overridden",
"salut": "le monde", "salut": "le monde",
"additional": "value", "additional": "value",
}, },

@ -70,7 +70,7 @@ template = "default.md"
# Specify the list of directories which will automatically belong to the group # Specify the list of directories which will automatically belong to the group
# with the optional ` + "`" + `paths` + "`" + ` property. # with the optional ` + "`" + `paths` + "`" + ` property.
# #
# Omiting ` + "`" + `paths` + "`" + ` is equivalent to providing a single path equal to the name of # Omitting ` + "`" + `paths` + "`" + ` is equivalent to providing a single path equal to the name of
# the group. This can be useful to quickly declare a group by the name of the # the group. This can be useful to quickly declare a group by the name of the
# directory it applies to. # directory it applies to.
@ -315,18 +315,18 @@ func (zk *Zk) DirAt(path string, overrides ...ConfigOverrides) (*Dir, error) {
func (zk *Zk) findConfigForDirNamed(name string, overrides []ConfigOverrides) (GroupConfig, error) { func (zk *Zk) findConfigForDirNamed(name string, overrides []ConfigOverrides) (GroupConfig, error) {
// If there's a Group overrides, attempt to find a matching group. // If there's a Group overrides, attempt to find a matching group.
overridenGroup := "" overriddenGroup := ""
for _, o := range overrides { for _, o := range overrides {
if !o.Group.IsNull() { if !o.Group.IsNull() {
overridenGroup = o.Group.Unwrap() overriddenGroup = o.Group.Unwrap()
if group, ok := zk.Config.Groups[overridenGroup]; ok { if group, ok := zk.Config.Groups[overriddenGroup]; ok {
return group, nil return group, nil
} }
} }
} }
if overridenGroup != "" { if overriddenGroup != "" {
return GroupConfig{}, fmt.Errorf("%s: group not find in the config file", overridenGroup) return GroupConfig{}, fmt.Errorf("%s: group not find in the config file", overriddenGroup)
} }
for groupName, group := range zk.Config.Groups { for groupName, group := range zk.Config.Groups {

@ -253,9 +253,9 @@ func TestDirAtWithOverrides(t *testing.T) {
dir, err := zk.DirAt(".", dir, err := zk.DirAt(".",
ConfigOverrides{ ConfigOverrides{
BodyTemplatePath: opt.NewString("overriden-template"), BodyTemplatePath: opt.NewString("overridden-template"),
Extra: map[string]string{ Extra: map[string]string{
"hello": "overriden", "hello": "overridden",
"additional": "value", "additional": "value",
}, },
}, },
@ -272,7 +272,7 @@ func TestDirAtWithOverrides(t *testing.T) {
Paths: []string{}, Paths: []string{},
Note: NoteConfig{ Note: NoteConfig{
FilenameTemplate: "{{id}}.note", FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("overriden-template"), BodyTemplatePath: opt.NewString("overridden-template"),
IDOptions: IDOptions{ IDOptions: IDOptions{
Length: 4, Length: 4,
Charset: CharsetLetters, Charset: CharsetLetters,
@ -280,7 +280,7 @@ func TestDirAtWithOverrides(t *testing.T) {
}, },
}, },
Extra: map[string]string{ Extra: map[string]string{
"hello": "overriden", "hello": "overridden",
"additional": "value2", "additional": "value2",
"additional2": "value3", "additional2": "value3",
}, },

@ -11,6 +11,7 @@ The following variables are available in the templates used when formatting note
| `snippets` | [string] | List of context-sensitive relevant excerpts from the note | | `snippets` | [string] | List of context-sensitive relevant excerpts from the note |
| `raw-content` | string | The full raw content of the note file | | `raw-content` | string | The full raw content of the note file |
| `word-count` | int | Number of words in the note | | `word-count` | int | Number of words in the note |
| `tags` | [string] | List of tags found in the note |
| `created` | date | Date of creation of the note | | `created` | date | Date of creation of the note |
| `modified` | date | Last date of modification of the note | | `modified` | date | Last date of modification of the note |
| `checksum` | string | SHA-256 checksum of the note file | | `checksum` | string | SHA-256 checksum of the note file |

@ -22,7 +22,7 @@ func ConvertQuery(query string) string {
')': true, ')': true,
} }
// Indicates whether the current term was explicitely quoted in the query. // Indicates whether the current term was explicitly quoted in the query.
inQuote := false inQuote := false
// Current term being read. // Current term being read.
term := "" term := ""

@ -41,11 +41,6 @@ func Walk(basePath string, extension string, logger util.Logger) <-chan Metadata
return nil return nil
} }
curDir := filepath.Dir(path)
if curDir == "." {
curDir = ""
}
c <- Metadata{ c <- Metadata{
Path: path, Path: path,
Modified: info.ModTime().UTC(), Modified: info.ModTime().UTC(),

Loading…
Cancel
Save