mirror of
https://github.com/mickael-menu/zk
synced 2024-11-07 15:20:21 +00:00
Add the --mention filtering option (#8)
This commit is contained in:
parent
87b5201583
commit
314024eae0
@ -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,136 +527,137 @@ 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)
|
||||
if opts.ExcludeIds != nil {
|
||||
whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIds, ",")+")")
|
||||
}
|
||||
|
||||
negate := false
|
||||
globs := make([]string, 0)
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if opts.Tags != nil {
|
||||
separatorRegex := regexp.MustCompile(`(\ OR\ )|\|`)
|
||||
for _, tagsArg := range opts.Tags {
|
||||
tags := separatorRegex.Split(tagsArg, -1)
|
||||
|
||||
if strings.HasPrefix(tag, "-") {
|
||||
negate = true
|
||||
tag = strings.TrimPrefix(tag, "-")
|
||||
} else if strings.HasPrefix(tag, "NOT") {
|
||||
negate = true
|
||||
tag = strings.TrimPrefix(tag, "NOT")
|
||||
}
|
||||
negate := false
|
||||
globs := make([]string, 0)
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
|
||||
tag = strings.TrimSpace(tag)
|
||||
if len(tag) == 0 {
|
||||
continue
|
||||
}
|
||||
globs = append(globs, "t.name GLOB ?")
|
||||
args = append(args, 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 len(globs) == 0 {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if len(tag) == 0 {
|
||||
continue
|
||||
}
|
||||
if negate && len(globs) > 1 {
|
||||
return nil, fmt.Errorf("cannot negate a tag in a OR group: %s", tagsArg)
|
||||
}
|
||||
globs = append(globs, "t.name GLOB ?")
|
||||
args = append(args, tag)
|
||||
}
|
||||
|
||||
expr := "n.id"
|
||||
if negate {
|
||||
expr += " NOT"
|
||||
}
|
||||
expr += fmt.Sprintf(` 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:
|
||||
if len(filter) == 0 {
|
||||
break
|
||||
}
|
||||
regexes := make([]string, 0)
|
||||
for _, path := range filter {
|
||||
regexes = append(regexes, "n.path NOT REGEXP ?")
|
||||
args = append(args, pathRegex(path))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
case note.LinkToFilter:
|
||||
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(?)"
|
||||
}
|
||||
|
||||
whereExprs = append(whereExprs, fmt.Sprintf("%s %s %s", field, op, value))
|
||||
args = append(args, filter.Date)
|
||||
|
||||
case note.InteractiveFilter:
|
||||
// No user interaction possible from here.
|
||||
break
|
||||
|
||||
default:
|
||||
panic(fmt.Sprintf("%v: unknown filter type", filter))
|
||||
WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
|
||||
)`,
|
||||
note.CollectionKindTag,
|
||||
strings.Join(globs, " OR "),
|
||||
)
|
||||
whereExprs = append(whereExprs, expr)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
if opts.Orphan {
|
||||
whereExprs = append(whereExprs, `n.id NOT IN (
|
||||
SELECT target_id FROM links WHERE target_id IS NOT NULL
|
||||
)`)
|
||||
}
|
||||
|
||||
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{}
|
||||
for _, sorter := range opts.Sorters {
|
||||
orderTerms = append(orderTerms, orderTerm(sorter))
|
||||
@ -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: ¬e.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: ¬e.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: ¬e.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: ¬e.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: ¬e.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: ¬e.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: ¬e.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: ¬e.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: ¬e.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: ¬e.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 paths, ok := relPaths(zk, filtering.LinkedBy); ok {
|
||||
opts.LinkedBy = ¬e.LinkedByFilter{
|
||||
Paths: paths,
|
||||
Negate: false,
|
||||
Recursive: filtering.Recursive,
|
||||
MaxDistance: filtering.MaxDistance,
|
||||
}
|
||||
} else if paths, ok := relPaths(zk, filtering.NoLinkedBy); ok {
|
||||
opts.LinkedBy = ¬e.LinkedByFilter{
|
||||
Paths: paths,
|
||||
Negate: true,
|
||||
}
|
||||
}
|
||||
|
||||
if paths, ok := relPaths(zk, filtering.LinkTo); ok {
|
||||
opts.LinkTo = ¬e.LinkToFilter{
|
||||
Paths: paths,
|
||||
Negate: false,
|
||||
Recursive: filtering.Recursive,
|
||||
MaxDistance: filtering.MaxDistance,
|
||||
}
|
||||
} else if paths, ok := relPaths(zk, filtering.NoLinkTo); ok {
|
||||
opts.LinkTo = ¬e.LinkToFilter{
|
||||
Paths: paths,
|
||||
Negate: true,
|
||||
}
|
||||
}
|
||||
|
||||
if paths, ok := relPaths(zk, filtering.Related); ok {
|
||||
opts.Related = paths
|
||||
}
|
||||
|
||||
opts.Orphan = filtering.Orphan
|
||||
|
||||
if filtering.Created != "" {
|
||||
date, err := parseDate(filtering.Created)
|
||||
start, end, err := parseDayRange(filtering.Created)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
opts.CreatedStart = &start
|
||||
opts.CreatedEnd = &end
|
||||
} else {
|
||||
if filtering.CreatedBefore != "" {
|
||||
date, err := parseDate(filtering.CreatedBefore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.CreatedEnd = &date
|
||||
}
|
||||
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 filtering.CreatedAfter != "" {
|
||||
date, err := parseDate(filtering.CreatedAfter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.CreatedStart = &date
|
||||
}
|
||||
filters = append(filters, note.DateFilter{
|
||||
Date: date,
|
||||
Field: note.DateCreated,
|
||||
Direction: note.DateAfter,
|
||||
})
|
||||
}
|
||||
|
||||
if filtering.Modified != "" {
|
||||
date, err := parseDate(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.DateOn,
|
||||
})
|
||||
}
|
||||
|
||||
if filtering.ModifiedBefore != "" {
|
||||
date, err := parseDate(filtering.ModifiedBefore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
opts.ModifiedStart = &start
|
||||
opts.ModifiedEnd = &end
|
||||
} else {
|
||||
if filtering.ModifiedBefore != "" {
|
||||
date, err := parseDate(filtering.ModifiedBefore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.ModifiedEnd = &date
|
||||
}
|
||||
filters = append(filters, note.DateFilter{
|
||||
Date: date,
|
||||
Field: note.DateModified,
|
||||
Direction: note.DateBefore,
|
||||
})
|
||||
}
|
||||
|
||||
if filtering.ModifiedAfter != "" {
|
||||
date, err := parseDate(filtering.ModifiedAfter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if filtering.ModifiedAfter != "" {
|
||||
date, err := parseDate(filtering.ModifiedAfter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.ModifiedStart = &date
|
||||
}
|
||||
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{})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return ¬e.FinderOpts{
|
||||
Filters: filters,
|
||||
Sorters: sorters,
|
||||
Limit: filtering.Limit,
|
||||
}, nil
|
||||
opts.Limit = filtering.Limit
|
||||
|
||||
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.
|
||||
|
22
docs/note-frontmatter.md
Normal file
22
docs/note-frontmatter.md
Normal file
@ -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…
Reference in New Issue
Block a user