Add the --mention filtering option (#8)

pull/14/head
Mickaël Menu 3 years ago committed by GitHub
parent 87b5201583
commit 314024eae0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -12,7 +12,11 @@ All notable changes to this project will be documented in this file.
* If you want to exclude notes having a particular tag, prefix it with `-` or `NOT` (all caps), e.g. `--tag "NOT done"`.
* Use glob patterns to match multiple tags, e.g. `--tag "book-*"`.
* Many tag flavors are supported: `#hashtags`, `:colon:separated:tags:` ([opt-in](docs/note-format.md)) and even Bear's [`#multi-word tags#`](https://blog.bear.app/2017/11/bear-tips-how-to-create-multi-word-tags/) ([opt-in](docs/note-format.md)). If you prefer to use a YAML frontmatter, list your tags with the key `tags` or `keywords`.
* Print metadata from the YAML frontmatter in `list` output using `{{metadata.<key>}}`, e.g. `{{metadata.description}}`. Keys are normalized to lower case.
* Find every mention of a note in your notebook with `--mention file.md`.
* This will look for occurrences of the note's title in other notes.
* To refer to a note using several names, you can use the [YAML frontmatter key `aliases`](https://publish.obsidian.md/help/How+to/Add+aliases+to+note). For example the note titled "Artificial Intelligence" might have: `aliases: [AI, robot]`
* To find only unlinked mentions, pair it with `--no-link-to`, e.g. `--mention file.md --no-link-to file.md`.
* Print metadata from the [YAML frontmatter](docs/note-frontmatter.md) in `list` output using `{{metadata.<key>}}`, e.g. `{{metadata.description}}`. Keys are normalized to lower case.
* Use the YAML frontmatter key `date` for the note creation date, when provided.
* Access environment variables from note templates with the `env.<key>` template variable, e.g. `{{env.PATH}}`.

@ -48,12 +48,11 @@ func NewNoteFinder(opts NoteFinderOpts, finder note.Finder, terminal *term.Termi
}
func (f *NoteFinder) Find(opts note.FinderOpts) ([]note.Match, error) {
isInteractive, opts := popInteractiveFilter(opts)
selectedMatches := make([]note.Match, 0)
matches, err := f.finder.Find(opts)
relPaths := []string{}
if !isInteractive || !f.terminal.IsInteractive() || err != nil || (!f.opts.AlwaysFilter && len(matches) == 0) {
if !opts.Interactive || !f.terminal.IsInteractive() || err != nil || (!f.opts.AlwaysFilter && len(matches) == 0) {
return matches, err
}
@ -122,19 +121,3 @@ func (f *NoteFinder) Find(opts note.FinderOpts) ([]note.Match, error) {
return selectedMatches, nil
}
func popInteractiveFilter(opts note.FinderOpts) (bool, note.FinderOpts) {
isInteractive := false
filters := make([]note.Filter, 0)
for _, filter := range opts.Filters {
if f, ok := filter.(note.InteractiveFilter); ok {
isInteractive = bool(f)
} else {
filters = append(filters, filter)
}
}
opts.Filters = filters
return isInteractive, opts
}

@ -1,7 +1,7 @@
- id: 1
path: "log/2021-01-03.md"
sortable_path: "log/2021-01-03.md"
title: "January 3, 2021"
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"
@ -35,7 +35,7 @@
checksum: "iaefhv"
created: "2019-12-04T11:59:11Z"
modified: "2019-12-04T12:17:21Z"
metadata: "{}"
metadata: '{"aliases": ["First page"]}'
- id: 4
path: "f39c8.md"
@ -55,7 +55,7 @@
sortable_path: "ref/test/b.md"
title: "A nested note"
lead: "This one is in a sub sub directory"
body: "This one is in a sub sub directory"
body: "This one is in a sub sub directory, not the first page"
raw_content: "# A nested note\nThis one is in a sub sub directory"
word_count: 8
checksum: "yvwbae"

@ -15,6 +15,7 @@ import (
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/fts5"
"github.com/mickael-menu/zk/util/icu"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/paths"
strutil "github.com/mickael-menu/zk/util/strings"
)
@ -304,6 +305,11 @@ func idForRow(row *sql.Row) (core.NoteId, error) {
func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
matches := make([]note.Match, 0)
opts, err := d.expandMentionsIntoMatch(opts)
if err != nil {
return matches, err
}
rows, err := d.findRows(opts)
if err != nil {
return matches, err
@ -328,10 +334,9 @@ func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
continue
}
var metadata map[string]interface{}
err = json.Unmarshal([]byte(metadataJSON), &metadata)
metadata, err := d.unmarshalMetadata(metadataJSON)
if err != nil {
d.logger.Err(errors.Wrapf(err, "cannot parse note metadata from JSON: %s", path))
d.logger.Err(errors.Wrap(err, path))
}
matches = append(matches, note.Match{
@ -366,6 +371,82 @@ func parseListFromNullString(str sql.NullString) []string {
return list
}
// expandMentionsIntoMatch finds the titles associated with the notes in opts.Mention to
// expand them into the opts.Match predicate.
func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts, error) {
notFoundErr := fmt.Errorf("could not find notes at: " + strings.Join(opts.Mention, ","))
if opts.Mention == nil {
return opts, nil
}
// Find the IDs for the mentioned paths.
ids, err := d.findIdsByPathPrefixes(opts.Mention)
if err != nil {
return opts, err
}
if len(ids) == 0 {
return opts, notFoundErr
}
// Exclude the mentioned notes from the results.
if opts.ExcludeIds == nil {
opts.ExcludeIds = ids
} else {
for _, id := range ids {
opts.ExcludeIds = append(opts.ExcludeIds, id)
}
}
// Find their titles.
titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + d.joinIds(ids, ",") + ")"
rows, err := d.tx.Query(titlesQuery)
if err != nil {
return opts, err
}
defer rows.Close()
titles := []string{}
for rows.Next() {
var title, metadataJSON string
err := rows.Scan(&title, &metadataJSON)
if err != nil {
return opts, err
}
titles = append(titles, `"`+title+`"`)
// Support `aliases` key in the YAML frontmatter, like Obsidian:
// https://publish.obsidian.md/help/How+to/Add+aliases+to+note
metadata, err := d.unmarshalMetadata(metadataJSON)
if err != nil {
d.logger.Err(err)
} else {
if aliases, ok := metadata["aliases"]; ok {
switch aliases := aliases.(type) {
case []interface{}:
for _, alias := range aliases {
titles = append(titles, `"`+fmt.Sprint(alias)+`"`)
}
case string:
titles = append(titles, `"`+aliases+`"`)
}
}
}
}
if len(titles) == 0 {
return opts, notFoundErr
}
// Expand the titles in the match predicate.
match := opts.Match.String()
match += " (" + strings.Join(titles, " OR ") + ")"
opts.Match = opt.NewString(match)
return opts, nil
}
func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
snippetCol := `n.lead`
joinClauses := []string{}
@ -446,134 +527,135 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
return nil
}
for _, filter := range opts.Filters {
switch filter := filter.(type) {
if !opts.Match.IsNull() {
snippetCol = `snippet(notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20)`
joinClauses = append(joinClauses, "JOIN notes_fts ON n.id = notes_fts.rowid")
additionalOrderTerms = append(additionalOrderTerms, `bm25(notes_fts, 1000.0, 500.0, 1.0)`)
whereExprs = append(whereExprs, "notes_fts MATCH ?")
args = append(args, fts5.ConvertQuery(opts.Match.String()))
}
case note.MatchFilter:
snippetCol = `snippet(notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20)`
joinClauses = append(joinClauses, "JOIN notes_fts ON n.id = notes_fts.rowid")
additionalOrderTerms = append(additionalOrderTerms, `bm25(notes_fts, 1000.0, 500.0, 1.0)`)
whereExprs = append(whereExprs, "notes_fts MATCH ?")
args = append(args, fts5.ConvertQuery(string(filter)))
if opts.IncludePaths != nil {
regexes := make([]string, 0)
for _, path := range opts.IncludePaths {
regexes = append(regexes, "n.path REGEXP ?")
args = append(args, pathRegex(path))
}
whereExprs = append(whereExprs, strings.Join(regexes, " OR "))
}
case note.PathFilter:
if len(filter) == 0 {
break
}
regexes := make([]string, 0)
for _, path := range filter {
regexes = append(regexes, "n.path REGEXP ?")
args = append(args, pathRegex(path))
}
whereExprs = append(whereExprs, strings.Join(regexes, " OR "))
if opts.ExcludePaths != nil {
regexes := make([]string, 0)
for _, path := range opts.ExcludePaths {
regexes = append(regexes, "n.path NOT REGEXP ?")
args = append(args, pathRegex(path))
}
whereExprs = append(whereExprs, strings.Join(regexes, " AND "))
}
case note.TagFilter:
if len(filter) == 0 {
break
}
separatorRegex := regexp.MustCompile(`(\ OR\ )|\|`)
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")
}
if opts.ExcludeIds != nil {
whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIds, ",")+")")
}
tag = strings.TrimSpace(tag)
if len(tag) == 0 {
continue
}
globs = append(globs, "t.name GLOB ?")
args = append(args, tag)
}
if opts.Tags != nil {
separatorRegex := regexp.MustCompile(`(\ OR\ )|\|`)
for _, tagsArg := range opts.Tags {
tags := separatorRegex.Split(tagsArg, -1)
if len(globs) == 0 {
continue
}
if negate && len(globs) > 1 {
return nil, fmt.Errorf("cannot negate a tag in a OR group: %s", tagsArg)
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")
}
expr := "n.id"
if negate {
expr += " NOT"
tag = strings.TrimSpace(tag)
if len(tag) == 0 {
continue
}
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)
globs = append(globs, "t.name GLOB ?")
args = append(args, tag)
}
case note.ExcludePathFilter:
if len(filter) == 0 {
break
if len(globs) == 0 {
continue
}
regexes := make([]string, 0)
for _, path := range filter {
regexes = append(regexes, "n.path NOT REGEXP ?")
args = append(args, pathRegex(path))
if negate && len(globs) > 1 {
return nil, fmt.Errorf("cannot negate a tag in a OR group: %s", tagsArg)
}
whereExprs = append(whereExprs, strings.Join(regexes, " AND "))
case note.LinkedByFilter:
maxDistance = filter.MaxDistance
err := setupLinkFilter(filter.Paths, -1, filter.Negate, filter.Recursive)
if err != nil {
return nil, err
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.LinkToFilter:
maxDistance = filter.MaxDistance
err := setupLinkFilter(filter.Paths, 1, filter.Negate, filter.Recursive)
if err != nil {
return nil, err
}
if opts.LinkedBy != nil {
filter := opts.LinkedBy
maxDistance = filter.MaxDistance
err := setupLinkFilter(filter.Paths, -1, filter.Negate, filter.Recursive)
if err != nil {
return nil, err
}
}
case note.RelatedFilter:
maxDistance = 2
err := setupLinkFilter(filter, 0, false, true)
if err != nil {
return nil, err
}
groupBy += " HAVING MIN(l.distance) = 2"
case note.OrphanFilter:
whereExprs = append(whereExprs, `n.id NOT IN (
SELECT target_id FROM links WHERE target_id IS NOT NULL
)`)
case note.DateFilter:
value := "?"
field := "n." + dateField(filter)
op, ignoreTime := dateDirection(filter)
if ignoreTime {
field = "date(" + field + ")"
value = "date(?)"
}
if opts.LinkTo != nil {
filter := opts.LinkTo
maxDistance = filter.MaxDistance
err := setupLinkFilter(filter.Paths, 1, filter.Negate, filter.Recursive)
if err != nil {
return nil, err
}
}
whereExprs = append(whereExprs, fmt.Sprintf("%s %s %s", field, op, value))
args = append(args, filter.Date)
if opts.Related != nil {
maxDistance = 2
err := setupLinkFilter(opts.Related, 0, false, true)
if err != nil {
return nil, err
}
groupBy += " HAVING MIN(l.distance) = 2"
}
case note.InteractiveFilter:
// No user interaction possible from here.
break
if opts.Orphan {
whereExprs = append(whereExprs, `n.id NOT IN (
SELECT target_id FROM links WHERE target_id IS NOT NULL
)`)
}
default:
panic(fmt.Sprintf("%v: unknown filter type", filter))
}
if opts.CreatedStart != nil {
whereExprs = append(whereExprs, "created >= ?")
args = append(args, opts.CreatedStart)
}
if opts.CreatedEnd != nil {
whereExprs = append(whereExprs, "created < ?")
args = append(args, opts.CreatedEnd)
}
if opts.ModifiedStart != nil {
whereExprs = append(whereExprs, "modified >= ?")
args = append(args, opts.ModifiedStart)
}
if opts.ModifiedEnd != nil {
whereExprs = append(whereExprs, "modified < ?")
args = append(args, opts.ModifiedEnd)
}
orderTerms := []string{}
@ -642,30 +724,6 @@ SELECT note_id FROM notes_collections
return d.tx.Query(query, args...)
}
func dateField(filter note.DateFilter) string {
switch filter.Field {
case note.DateCreated:
return "created"
case note.DateModified:
return "modified"
default:
panic(fmt.Sprintf("%v: unknown note.DateField", filter.Field))
}
}
func dateDirection(filter note.DateFilter) (op string, ignoreTime bool) {
switch filter.Direction {
case note.DateOn:
return "=", true
case note.DateBefore:
return "<", false
case note.DateAfter:
return ">=", false
default:
panic(fmt.Sprintf("%v: unknown note.DateDirection", filter.Direction))
}
}
func orderTerm(sorter note.Sorter) string {
order := " ASC"
if !sorter.Ascending {
@ -712,3 +770,9 @@ func (d *NoteDAO) joinIds(ids []core.NoteId, delimiter string) string {
}
return strings.Join(strs, delimiter)
}
func (d *NoteDAO) unmarshalMetadata(metadataJSON string) (metadata map[string]interface{}, err error) {
err = json.Unmarshal([]byte(metadataJSON), &metadata)
err = errors.Wrapf(err, "cannot parse note metadata from JSON: %s", metadataJSON)
return
}

@ -9,6 +9,7 @@ import (
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/paths"
"github.com/mickael-menu/zk/util/test/assert"
)
@ -393,13 +394,8 @@ func TestNoteDAORemoveCascadeLinks(t *testing.T) {
func TestNoteDAOFindAll(t *testing.T) {
testNoteDAOFindPaths(t, note.FinderOpts{}, []string{
"ref/test/b.md",
"f39c8.md",
"ref/test/a.md",
"log/2021-02-04.md",
"index.md",
"log/2021-01-03.md",
"log/2021-01-04.md",
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md",
"log/2021-02-04.md", "index.md", "log/2021-01-04.md",
})
}
@ -412,9 +408,7 @@ 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)
testNoteDAOFindPaths(t, note.FinderOpts{Tags: tags}, expectedPaths)
}
test([]string{"fiction"}, []string{"log/2021-01-03.md"})
@ -433,9 +427,7 @@ func TestNoteDAOFindTag(t *testing.T) {
func TestNoteDAOFindMatch(t *testing.T) {
testNoteDAOFind(t,
note.FinderOpts{
Filters: []note.Filter{note.MatchFilter("daily | index")},
},
note.FinderOpts{Match: opt.NewString("daily | index")},
[]note.Match{
{
Metadata: note.Metadata{
@ -447,13 +439,34 @@ func TestNoteDAOFindMatch(t *testing.T) {
WordCount: 4,
Links: []note.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2019, 12, 4, 11, 59, 11, 0, time.UTC),
Modified: time.Date(2019, 12, 4, 12, 17, 21, 0, time.UTC),
Checksum: "iaefhv",
Metadata: map[string]interface{}{
"aliases": []interface{}{"First page"},
},
Created: time.Date(2019, 12, 4, 11, 59, 11, 0, time.UTC),
Modified: time.Date(2019, 12, 4, 12, 17, 21, 0, time.UTC),
Checksum: "iaefhv",
},
Snippets: []string{"<zk:match>Index</zk:match> of the Zettelkasten"},
},
{
Metadata: note.Metadata{
Path: "log/2021-01-03.md",
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",
WordCount: 3,
Links: []note.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
},
Created: 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",
},
Snippets: []string{"A <zk:match>daily</zk:match> note\n\nWith lot of content"},
},
{
Metadata: note.Metadata{
Path: "log/2021-02-04.md",
@ -488,25 +501,6 @@ func TestNoteDAOFindMatch(t *testing.T) {
},
Snippets: []string{"A second <zk:match>daily</zk:match> note"},
},
{
Metadata: note.Metadata{
Path: "log/2021-01-03.md",
Title: "January 3, 2021",
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",
WordCount: 3,
Links: []note.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
},
Created: 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",
},
Snippets: []string{"A <zk:match>daily</zk:match> note\n\nWith lot of content"},
},
},
)
}
@ -514,7 +508,7 @@ func TestNoteDAOFindMatch(t *testing.T) {
func TestNoteDAOFindMatchWithSort(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.MatchFilter("daily | index")},
Match: opt.NewString("daily | index"),
Sorters: []note.Sorter{
{Field: note.SortPath, Ascending: false},
},
@ -531,7 +525,7 @@ func TestNoteDAOFindMatchWithSort(t *testing.T) {
func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.PathFilter([]string{"log/2021-01-03.md"})},
IncludePaths: []string{"log/2021-01-03.md"},
},
[]string{"log/2021-01-03.md"},
)
@ -541,7 +535,7 @@ func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) {
func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.PathFilter([]string{"log/2021-01"})},
IncludePaths: []string{"log/2021-01"},
},
[]string{"log/2021-01-03.md", "log/2021-01-04.md"},
)
@ -551,15 +545,15 @@ func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) {
func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.PathFilter([]string{"lo"})},
IncludePaths: []string{"lo"},
},
[]string{},
)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.PathFilter([]string{"log"})},
IncludePaths: []string{"log"},
},
[]string{"log/2021-02-04.md", "log/2021-01-03.md", "log/2021-01-04.md"},
[]string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"},
)
}
@ -567,7 +561,7 @@ func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) {
func TestNoteDAOFindInMultiplePaths(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.PathFilter([]string{"ref", "index.md"})},
IncludePaths: []string{"ref", "index.md"},
},
[]string{"ref/test/b.md", "ref/test/a.md", "index.md"},
)
@ -576,7 +570,7 @@ func TestNoteDAOFindInMultiplePaths(t *testing.T) {
func TestNoteDAOFindExcludingPath(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.ExcludePathFilter([]string{"log"})},
ExcludePaths: []string{"log"},
},
[]string{"ref/test/b.md", "f39c8.md", "ref/test/a.md", "index.md"},
)
@ -585,20 +579,93 @@ func TestNoteDAOFindExcludingPath(t *testing.T) {
func TestNoteDAOFindExcludingMultiplePaths(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.ExcludePathFilter([]string{"ref", "log/2021-01"})},
ExcludePaths: []string{"ref", "log/2021-01"},
},
[]string{"f39c8.md", "log/2021-02-04.md", "index.md"},
)
}
func TestNoteDAOFindMentions(t *testing.T) {
testNoteDAOFind(t,
note.FinderOpts{Mention: []string{"log/2021-01-03.md", "index.md"}},
[]note.Match{
{
Metadata: note.Metadata{
Path: "ref/test/b.md",
Title: "A nested note",
Lead: "This one is in a sub sub directory",
Body: "This one is in a sub sub directory, not the first page",
RawContent: "# A nested note\nThis one is in a sub sub directory",
WordCount: 8,
Links: []note.Link{},
Tags: []string{"adventure", "history"},
Metadata: map[string]interface{}{},
Created: time.Date(2019, 11, 20, 20, 32, 56, 0, time.UTC),
Modified: time.Date(2019, 11, 20, 20, 34, 6, 0, time.UTC),
Checksum: "yvwbae",
},
Snippets: []string{"This one is in a sub sub directory, not the <zk:match>first page</zk:match>"},
},
{
Metadata: note.Metadata{
Path: "log/2021-02-04.md",
Title: "February 4, 2021",
Lead: "A third daily note",
Body: "A third daily note",
RawContent: "# A third daily note",
WordCount: 4,
Links: []note.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
Modified: time.Date(2020, 11, 10, 8, 20, 18, 0, time.UTC),
Checksum: "earkte",
},
Snippets: []string{"A third <zk:match>daily note</zk:match>"},
},
{
Metadata: note.Metadata{
Path: "log/2021-01-04.md",
Title: "January 4, 2021",
Lead: "A second daily note",
Body: "A second daily note",
RawContent: "# A second daily note",
WordCount: 4,
Links: []note.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: 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",
},
Snippets: []string{"A second <zk:match>daily note</zk:match>"},
},
},
)
}
// Common use case: `--mention x --no-link-to x`
func TestNoteDAOFindUnlinkedMentions(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Mention: []string{"log/2021-01-03.md", "index.md"},
LinkTo: &note.LinkToFilter{
Paths: []string{"log/2021-01-03.md", "index.md"},
Negate: true,
},
},
[]string{"ref/test/b.md", "log/2021-02-04.md"},
)
}
func TestNoteDAOFindLinkedBy(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkedByFilter{
LinkedBy: &note.LinkedByFilter{
Paths: []string{"f39c8.md", "log/2021-01-03"},
Negate: false,
Recursive: false,
}},
},
},
[]string{"ref/test/a.md", "log/2021-01-03.md", "log/2021-01-04.md"},
)
@ -607,11 +674,11 @@ func TestNoteDAOFindLinkedBy(t *testing.T) {
func TestNoteDAOFindLinkedByRecursive(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkedByFilter{
LinkedBy: &note.LinkedByFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
}},
},
},
[]string{"index.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md"},
)
@ -620,12 +687,12 @@ func TestNoteDAOFindLinkedByRecursive(t *testing.T) {
func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkedByFilter{
LinkedBy: &note.LinkedByFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
MaxDistance: 2,
}},
},
},
[]string{"index.md", "f39c8.md"},
)
@ -634,7 +701,7 @@ func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) {
func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
testNoteDAOFind(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkedByFilter{Paths: []string{"f39c8.md"}}},
LinkedBy: &note.LinkedByFilter{Paths: []string{"f39c8.md"}},
},
[]note.Match{
{
@ -662,7 +729,7 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
{
Metadata: note.Metadata{
Path: "log/2021-01-03.md",
Title: "January 3, 2021",
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",
@ -687,11 +754,11 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
func TestNoteDAOFindNotLinkedBy(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkedByFilter{
LinkedBy: &note.LinkedByFilter{
Paths: []string{"f39c8.md", "log/2021-01-03"},
Negate: true,
Recursive: false,
}},
},
},
[]string{"ref/test/b.md", "f39c8.md", "log/2021-02-04.md", "index.md"},
)
@ -700,11 +767,11 @@ func TestNoteDAOFindNotLinkedBy(t *testing.T) {
func TestNoteDAOFindLinkTo(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkToFilter{
LinkTo: &note.LinkToFilter{
Paths: []string{"log/2021-01-04", "ref/test/a.md"},
Negate: false,
Recursive: false,
}},
},
},
[]string{"f39c8.md", "log/2021-01-03.md"},
)
@ -713,11 +780,11 @@ func TestNoteDAOFindLinkTo(t *testing.T) {
func TestNoteDAOFindLinkToRecursive(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkToFilter{
LinkTo: &note.LinkToFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
}},
},
},
[]string{"log/2021-01-03.md", "f39c8.md", "index.md"},
)
@ -726,12 +793,12 @@ func TestNoteDAOFindLinkToRecursive(t *testing.T) {
func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkToFilter{
LinkTo: &note.LinkToFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
MaxDistance: 2,
}},
},
},
[]string{"log/2021-01-03.md", "f39c8.md"},
)
@ -740,7 +807,7 @@ func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) {
func TestNoteDAOFindNotLinkTo(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.LinkToFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true}},
LinkTo: &note.LinkToFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true},
},
[]string{"ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md"},
)
@ -749,14 +816,14 @@ func TestNoteDAOFindNotLinkTo(t *testing.T) {
func TestNoteDAOFindRelated(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.RelatedFilter([]string{"log/2021-02-04"})},
Related: []string{"log/2021-02-04"},
},
[]string{},
)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.RelatedFilter([]string{"log/2021-01-03.md"})},
Related: []string{"log/2021-01-03.md"},
},
[]string{"index.md"},
)
@ -764,98 +831,70 @@ func TestNoteDAOFindRelated(t *testing.T) {
func TestNoteDAOFindOrphan(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{note.OrphanFilter{}},
},
note.FinderOpts{Orphan: true},
[]string{"ref/test/b.md", "log/2021-02-04.md"},
)
}
func TestNoteDAOFindCreatedOn(t *testing.T) {
start := time.Date(2020, 11, 22, 0, 0, 0, 0, time.UTC)
end := time.Date(2020, 11, 23, 0, 0, 0, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{
note.DateFilter{
Date: time.Date(2020, 11, 22, 10, 12, 45, 0, time.UTC),
Field: note.DateCreated,
Direction: note.DateOn,
},
},
CreatedStart: &start,
CreatedEnd: &end,
},
[]string{"log/2021-01-03.md"},
)
}
func TestNoteDAOFindCreatedBefore(t *testing.T) {
end := time.Date(2019, 12, 04, 11, 59, 11, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{
note.DateFilter{
Date: time.Date(2019, 12, 04, 11, 59, 11, 0, time.UTC),
Field: note.DateCreated,
Direction: note.DateBefore,
},
},
CreatedEnd: &end,
},
[]string{"ref/test/b.md", "ref/test/a.md"},
)
}
func TestNoteDAOFindCreatedAfter(t *testing.T) {
start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{
note.DateFilter{
Date: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Field: note.DateCreated,
Direction: note.DateAfter,
},
},
CreatedStart: &start,
},
[]string{"log/2021-02-04.md", "log/2021-01-03.md", "log/2021-01-04.md"},
[]string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"},
)
}
func TestNoteDAOFindModifiedOn(t *testing.T) {
start := time.Date(2020, 01, 20, 0, 0, 0, 0, time.UTC)
end := time.Date(2020, 01, 21, 0, 0, 0, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{
note.DateFilter{
Date: time.Date(2020, 01, 20, 10, 12, 45, 0, time.UTC),
Field: note.DateModified,
Direction: note.DateOn,
},
},
ModifiedStart: &start,
ModifiedEnd: &end,
},
[]string{"f39c8.md"},
)
}
func TestNoteDAOFindModifiedBefore(t *testing.T) {
end := time.Date(2020, 01, 20, 8, 52, 42, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{
note.DateFilter{
Date: time.Date(2020, 01, 20, 8, 52, 42, 0, time.UTC),
Field: note.DateModified,
Direction: note.DateBefore,
},
},
ModifiedEnd: &end,
},
[]string{"ref/test/b.md", "ref/test/a.md", "index.md"},
)
}
func TestNoteDAOFindModifiedAfter(t *testing.T) {
start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
Filters: []note.Filter{
note.DateFilter{
Date: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Field: note.DateModified,
Direction: note.DateAfter,
},
},
ModifiedStart: &start,
},
[]string{"log/2021-01-03.md", "log/2021-01-04.md"},
)
@ -896,12 +935,12 @@ func TestNoteDAOFindSortPath(t *testing.T) {
func TestNoteDAOFindSortTitle(t *testing.T) {
testNoteDAOFindSort(t, note.SortTitle, true, []string{
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md",
"index.md", "log/2021-01-03.md", "log/2021-01-04.md",
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md",
"log/2021-02-04.md", "index.md", "log/2021-01-04.md",
})
testNoteDAOFindSort(t, note.SortTitle, false, []string{
"log/2021-01-04.md", "log/2021-01-03.md", "index.md",
"log/2021-02-04.md", "ref/test/a.md", "f39c8.md", "ref/test/b.md",
"log/2021-01-04.md", "index.md", "log/2021-02-04.md",
"log/2021-01-03.md", "ref/test/a.md", "f39c8.md", "ref/test/b.md",
})
}

@ -3,10 +3,13 @@ package sqlite
import (
"database/sql"
"sync"
"github.com/mickael-menu/zk/util/errors"
)
// LazyStmt is a wrapper around a sql.Stmt which will be evaluated on first use.
type LazyStmt struct {
query string
create func() (*sql.Stmt, error)
stmt *sql.Stmt
err error
@ -16,6 +19,7 @@ type LazyStmt struct {
// NewLazyStmt creates a new lazy statement bound to the given transaction.
func NewLazyStmt(tx *sql.Tx, query string) *LazyStmt {
return &LazyStmt{
query: query,
create: func() (*sql.Stmt, error) { return tx.Prepare(query) },
}
}
@ -24,7 +28,7 @@ func (s *LazyStmt) Stmt() (*sql.Stmt, error) {
s.once.Do(func() {
s.stmt, s.err = s.create()
})
return s.stmt, s.err
return s.stmt, s.wrapErr(s.err)
}
func (s *LazyStmt) Exec(args ...interface{}) (sql.Result, error) {
@ -32,7 +36,8 @@ func (s *LazyStmt) Exec(args ...interface{}) (sql.Result, error) {
if err != nil {
return nil, err
}
return stmt.Exec(args...)
res, err := stmt.Exec(args...)
return res, s.wrapErr(err)
}
func (s *LazyStmt) Query(args ...interface{}) (*sql.Rows, error) {
@ -40,7 +45,8 @@ func (s *LazyStmt) Query(args ...interface{}) (*sql.Rows, error) {
if err != nil {
return nil, err
}
return stmt.Query(args...)
rows, err := stmt.Query(args...)
return rows, s.wrapErr(err)
}
func (s *LazyStmt) QueryRow(args ...interface{}) (*sql.Row, error) {
@ -50,3 +56,7 @@ func (s *LazyStmt) QueryRow(args ...interface{}) (*sql.Row, error) {
}
return stmt.QueryRow(args...), nil
}
func (s *LazyStmt) wrapErr(err error) error {
return errors.Wrapf(err, "database query: %s", s.query)
}

@ -6,6 +6,7 @@ import (
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/opt"
"github.com/tj/go-naturaldate"
)
@ -13,17 +14,17 @@ import (
type Filtering struct {
Path []string `group:filter arg optional placeholder:PATH help:"Find notes matching the given path, including its descendants."`
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."`
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`
LinkedBy []string `group:filter short:l placeholder:PATH help:"Find notes which are linked by the given ones." xor:link`
LinkTo []string `group:filter short:L placeholder:PATH help:"Find notes which are linking to the given ones." xor:link`
// FIXME: I'm not confident this is a useful option.
// NotLinkedBy []string `group:filter placeholder:PATH help:"Find notes which are not linked by the given ones." xor:link`
// NotLinkTo []string `group:filter placeholder:PATH help:"Find notes which are not linking to the given notes." xor:link`
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."`
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."`
LinkedBy []string `group:filter short:l placeholder:PATH help:"Find notes which are linked by the given ones." xor:link`
NoLinkedBy []string `group:filter placeholder:PATH help:"Find notes which are not linked by the given ones." xor:link`
LinkTo []string `group:filter short:L placeholder:PATH help:"Find notes which are linking to the given ones." xor:link`
NoLinkTo []string `group:filter placeholder:PATH help:"Find notes which are not linking to the given notes." xor:link`
Orphan bool `group:filter help:"Find notes which are not linked by any other note." xor:link`
Related []string `group:filter placeholder:PATH help:"Find notes which might be related to the given ones." xor:link`
MaxDistance int `group:filter placeholder:COUNT help:"Maximum distance between two linked notes."`
Recursive bool `group:filter short:r help:"Follow links recursively."`
@ -42,157 +43,119 @@ type Sorting struct {
// NewFinderOpts creates an instance of note.FinderOpts from a set of user flags.
func NewFinderOpts(zk *zk.Zk, filtering Filtering, sorting Sorting) (*note.FinderOpts, error) {
filters := make([]note.Filter, 0)
opts := note.FinderOpts{}
paths, ok := relPaths(zk, filtering.Path)
if ok {
filters = append(filters, note.PathFilter(paths))
opts.Match = opt.NewNotEmptyString(filtering.Match)
if paths, ok := relPaths(zk, filtering.Path); ok {
opts.IncludePaths = paths
}
excludePaths, ok := relPaths(zk, filtering.Exclude)
if ok {
filters = append(filters, note.ExcludePathFilter(excludePaths))
if paths, ok := relPaths(zk, filtering.Exclude); ok {
opts.ExcludePaths = paths
}
if len(filtering.Tag) > 0 {
filters = append(filters, note.TagFilter(filtering.Tag))
opts.Tags = filtering.Tag
}
if filtering.Match != "" {
filters = append(filters, note.MatchFilter(filtering.Match))
if len(filtering.Mention) > 0 {
opts.Mention = filtering.Mention
}
if filtering.Created != "" {
date, err := parseDate(filtering.Created)
if err != nil {
return nil, err
if paths, ok := relPaths(zk, filtering.LinkedBy); ok {
opts.LinkedBy = &note.LinkedByFilter{
Paths: paths,
Negate: false,
Recursive: filtering.Recursive,
MaxDistance: filtering.MaxDistance,
}
filters = append(filters, note.DateFilter{
Date: date,
Field: note.DateCreated,
Direction: note.DateOn,
})
}
if filtering.CreatedBefore != "" {
date, err := parseDate(filtering.CreatedBefore)
if err != nil {
return nil, err
} else if paths, ok := relPaths(zk, filtering.NoLinkedBy); ok {
opts.LinkedBy = &note.LinkedByFilter{
Paths: paths,
Negate: true,
}
filters = append(filters, note.DateFilter{
Date: date,
Field: note.DateCreated,
Direction: note.DateBefore,
})
}
if filtering.CreatedAfter != "" {
date, err := parseDate(filtering.CreatedAfter)
if err != nil {
return nil, err
if paths, ok := relPaths(zk, filtering.LinkTo); ok {
opts.LinkTo = &note.LinkToFilter{
Paths: paths,
Negate: false,
Recursive: filtering.Recursive,
MaxDistance: filtering.MaxDistance,
}
} else if paths, ok := relPaths(zk, filtering.NoLinkTo); ok {
opts.LinkTo = &note.LinkToFilter{
Paths: paths,
Negate: true,
}
filters = append(filters, note.DateFilter{
Date: date,
Field: note.DateCreated,
Direction: note.DateAfter,
})
}
if filtering.Modified != "" {
date, err := parseDate(filtering.Modified)
if err != nil {
return nil, err
}
filters = append(filters, note.DateFilter{
Date: date,
Field: note.DateModified,
Direction: note.DateOn,
})
if paths, ok := relPaths(zk, filtering.Related); ok {
opts.Related = paths
}
if filtering.ModifiedBefore != "" {
date, err := parseDate(filtering.ModifiedBefore)
opts.Orphan = filtering.Orphan
if filtering.Created != "" {
start, end, err := parseDayRange(filtering.Created)
if err != nil {
return nil, err
}
filters = append(filters, note.DateFilter{
Date: date,
Field: note.DateModified,
Direction: note.DateBefore,
})
opts.CreatedStart = &start
opts.CreatedEnd = &end
} else {
if filtering.CreatedBefore != "" {
date, err := parseDate(filtering.CreatedBefore)
if err != nil {
return nil, err
}
opts.CreatedEnd = &date
}
if filtering.CreatedAfter != "" {
date, err := parseDate(filtering.CreatedAfter)
if err != nil {
return nil, err
}
opts.CreatedStart = &date
}
}
if filtering.ModifiedAfter != "" {
date, err := parseDate(filtering.ModifiedAfter)
if filtering.Modified != "" {
start, end, err := parseDayRange(filtering.Modified)
if err != nil {
return nil, err
}
filters = append(filters, note.DateFilter{
Date: date,
Field: note.DateModified,
Direction: note.DateAfter,
})
}
linkedByPaths, ok := relPaths(zk, filtering.LinkedBy)
if ok {
filters = append(filters, note.LinkedByFilter{
Paths: linkedByPaths,
Negate: false,
Recursive: filtering.Recursive,
MaxDistance: filtering.MaxDistance,
})
}
linkToPaths, ok := relPaths(zk, filtering.LinkTo)
if ok {
filters = append(filters, note.LinkToFilter{
Paths: linkToPaths,
Negate: false,
Recursive: filtering.Recursive,
MaxDistance: filtering.MaxDistance,
})
}
// notLinkedByPaths, ok := relPaths(zk, filtering.NotLinkedBy)
// if ok {
// filters = append(filters, note.LinkedByFilter{
// Paths: notLinkedByPaths,
// Negate: true,
// })
// }
// notLinkToPaths, ok := relPaths(zk, filtering.NotLinkTo)
// if ok {
// filters = append(filters, note.LinkToFilter{
// Paths: notLinkToPaths,
// Negate: true,
// })
// }
relatedPaths, ok := relPaths(zk, filtering.Related)
if ok {
filters = append(filters, note.RelatedFilter(relatedPaths))
}
if filtering.Orphan {
filters = append(filters, note.OrphanFilter{})
opts.ModifiedStart = &start
opts.ModifiedEnd = &end
} else {
if filtering.ModifiedBefore != "" {
date, err := parseDate(filtering.ModifiedBefore)
if err != nil {
return nil, err
}
opts.ModifiedEnd = &date
}
if filtering.ModifiedAfter != "" {
date, err := parseDate(filtering.ModifiedAfter)
if err != nil {
return nil, err
}
opts.ModifiedStart = &date
}
}
if filtering.Interactive {
filters = append(filters, note.InteractiveFilter(true))
}
opts.Interactive = filtering.Interactive
sorters, err := note.SortersFromStrings(sorting.Sort)
if err != nil {
return nil, err
}
opts.Sorters = sorters
opts.Limit = filtering.Limit
return &note.FinderOpts{
Filters: filters,
Sorters: sorters,
Limit: filtering.Limit,
}, nil
return &opts, nil
}
func relPaths(zk *zk.Zk, paths []string) ([]string, bool) {
@ -212,3 +175,19 @@ func parseDate(date string) (time.Time, error) {
}
return naturaldate.Parse(date, time.Now().UTC(), naturaldate.WithDirection(naturaldate.Past))
}
func parseDayRange(date string) (start time.Time, end time.Time, err error) {
day, err := parseDate(date)
if err != nil {
return
}
start = startOfDay(day)
end = start.AddDate(0, 0, 1)
return start, end, nil
}
func startOfDay(t time.Time) time.Time {
year, month, day := t.Date()
return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
}

@ -6,6 +6,9 @@ import (
"strings"
"time"
"unicode/utf8"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/util/opt"
)
// ErrCanceled is returned when the user cancelled an operation.
@ -20,9 +23,40 @@ type Finder interface {
// FinderOpts holds the option used to filter and order a list of notes.
type FinderOpts struct {
Filters []Filter
// Filter used to match the notes with FTS predicates.
Match opt.String
// Filter by note paths.
IncludePaths []string
// Filter excluding notes at the given paths.
ExcludePaths []string
// Filter excluding notes with the given IDs.
ExcludeIds []core.NoteId
// Filter by tags found in the notes.
Tags []string
// Filter the notes mentioning the given notes.
Mention []string
// Filter to select notes being linked by another one.
LinkedBy *LinkedByFilter
// Filter to select notes linking to another one.
LinkTo *LinkToFilter
// Filter to select notes which could might be related to the given notes paths.
Related []string
// Filter to select notes having no other notes linking to them.
Orphan bool
// Filter notes created after the given date.
CreatedStart *time.Time
// Filter notes created before the given date.
CreatedEnd *time.Time
// Filter notes modified after the given date.
ModifiedStart *time.Time
// Filter notes modified before the given date.
ModifiedEnd *time.Time
// Indicates that the user should select manually the notes.
Interactive bool
// Limits the number of results
Limit int
// Sorting criteria
Sorters []Sorter
Limit int
}
// Match holds information about a note matching the find options.
@ -32,21 +66,6 @@ type Match struct {
Snippets []string
}
// Filter is a sealed interface implemented by Finder filter criteria.
type Filter interface{ sealed() }
// MatchFilter is a note filter used to match its content with FTS predicates.
type MatchFilter string
// PathFilter is a note filter using path globs to match notes.
type PathFilter []string
// ExcludePathFilter is a note filter using path globs to exclude notes from the list.
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.
type LinkedByFilter struct {
Paths []string
@ -55,7 +74,7 @@ type LinkedByFilter struct {
MaxDistance int
}
// LinkToFilter is a note filter used to select notes being linked by another one.
// LinkToFilter is a note filter used to select notes linking to another one.
type LinkToFilter struct {
Paths []string
Negate bool
@ -63,49 +82,6 @@ type LinkToFilter struct {
MaxDistance int
}
// RelatedFilter is a note filter used to select notes which could might be
// related to the given notes.
type RelatedFilter []string
// OrphanFilter is a note filter used to select notes having no other notes linking to them.
type OrphanFilter struct{}
// DateFilter can be used to filter notes created or modified before, after or on a given date.
type DateFilter struct {
Date time.Time
Direction DateDirection
Field DateField
}
// InteractiveFilter lets the user select manually the notes.
type InteractiveFilter bool
func (f MatchFilter) sealed() {}
func (f PathFilter) sealed() {}
func (f ExcludePathFilter) sealed() {}
func (f TagFilter) sealed() {}
func (f LinkedByFilter) sealed() {}
func (f LinkToFilter) sealed() {}
func (f RelatedFilter) sealed() {}
func (f OrphanFilter) sealed() {}
func (f DateFilter) sealed() {}
func (f InteractiveFilter) sealed() {}
type DateDirection int
const (
DateOn DateDirection = iota + 1
DateBefore
DateAfter
)
type DateField int
const (
DateCreated DateField = iota + 1
DateModified
)
// Sorter represents an order term used to sort a list of notes.
type Sorter struct {
Field SortField

@ -157,6 +157,16 @@ This is such a useful command, that an alias might be helpful.
bl = "zk list --link-to $@"
```
### Locate unlinked mentions of a note
This alias can help you look for potential new links to establish, by listing every mention of a note in your notebook which is not already linked to it.
Note that we are using a single argument `$1` which is repeated for both options.
```toml
unlinked-mentions = "zk list --mention $1 --no-link-to $1"
```
### Browse the Git history of selected notes
This example showcases the "`xargs` formula" with a concrete example.

@ -181,6 +181,29 @@ Part of writing a great notebook is to establish links between related notes. Th
--related 200911172034
```
## Locate mentions in other notes
Another great way to look for potential new links is to find every mention of a note in your notebook.
```
--mention 200911172034
```
This option will locate the notes containing the note's title. To refer to a note using several names, you can use the [YAML frontmatter](note-frontmatter.md) to declare additional aliases. For example, a note titled "Artificial Intelligence" might have for aliases "AI" and "robot". This method is compatible with [Obsidian](https://publish.obsidian.md/help/How+to/Add+aliases+to+note).
```
---
title: Artificial Intelligence
aliases: [AI, robot]
---
```
To find only unlinked mentions, pair the `--mention` option with `--no-link-to` to remove notes which are already linked from the results.
```
--mention 200911172034 --no-link-to 200911172034
```
## Exclude notes from the results
To prevent certain notes from polluting the results, you can explicitly exclude them with `--exclude <path>` (or `-x`). This is particularly useful when you have a whole directory of notes to be ignored.

@ -0,0 +1,22 @@
# YAML frontmatter
Markdown being a simple format, it does not offer any way to attach additional metadata to a note. The community came up with a solution by inserting a YAML header at the top of each note to contain its metadata. This method is widely supported among Zettelkasten softwares, including `zk`.
```yaml
---
title: Improve the structure of essays by rewriting
date: 2011-05-16 09:58:57
keywords: [writing, essay, practice]
---
```
`zk` supports the following metadata:
| Key | Description |
|------------|-------------------------------------------------------------|
| `title` | Title of the note takes precedence over the first heading |
| `date` | Creation date takes precedence over the file date |
| `tags` | List of tags attached to this note |
| `keywords` | Alias for `tags` |
All metadata are indexed and can be printed in `zk list` output, using the template variable `{{metadata.<key>}}`, e.g. `{{metadata.description}}`. The keys are normalized to lower case.
Loading…
Cancel
Save