diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e8dd1..7bb79c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.}}`, 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.}}`, 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.` template variable, e.g. `{{env.PATH}}`. diff --git a/adapter/fzf/finder.go b/adapter/fzf/finder.go index 23e6a0a..f4a559f 100644 --- a/adapter/fzf/finder.go +++ b/adapter/fzf/finder.go @@ -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 -} diff --git a/adapter/sqlite/fixtures/default/notes.yml b/adapter/sqlite/fixtures/default/notes.yml index 26f49df..187bc29 100644 --- a/adapter/sqlite/fixtures/default/notes.yml +++ b/adapter/sqlite/fixtures/default/notes.yml @@ -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" diff --git a/adapter/sqlite/note_dao.go b/adapter/sqlite/note_dao.go index f58ef32..4e2d866 100644 --- a/adapter/sqlite/note_dao.go +++ b/adapter/sqlite/note_dao.go @@ -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, '', '', '…', 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, '', '', '…', 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 +} diff --git a/adapter/sqlite/note_dao_test.go b/adapter/sqlite/note_dao_test.go index 22a34f0..b958601 100644 --- a/adapter/sqlite/note_dao_test.go +++ b/adapter/sqlite/note_dao_test.go @@ -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{"Index 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 daily 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 daily 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 daily 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 first page"}, + }, + { + 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 daily note"}, + }, + { + 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 daily note"}, + }, + }, + ) +} + +// 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", }) } diff --git a/adapter/sqlite/stmt.go b/adapter/sqlite/stmt.go index e0ff6ee..a595379 100644 --- a/adapter/sqlite/stmt.go +++ b/adapter/sqlite/stmt.go @@ -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) +} diff --git a/cmd/finder_opts.go b/cmd/finder_opts.go index 23b6150..c0b7e6e 100644 --- a/cmd/finder_opts.go +++ b/cmd/finder_opts.go @@ -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()) +} diff --git a/core/note/find.go b/core/note/find.go index 4595d92..6534bc2 100644 --- a/core/note/find.go +++ b/core/note/find.go @@ -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 diff --git a/docs/config-alias.md b/docs/config-alias.md index 2ab392a..fe495d2 100644 --- a/docs/config-alias.md +++ b/docs/config-alias.md @@ -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. diff --git a/docs/note-filtering.md b/docs/note-filtering.md index a31e5d1..8e70663 100644 --- a/docs/note-filtering.md +++ b/docs/note-filtering.md @@ -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 ` (or `-x`). This is particularly useful when you have a whole directory of notes to be ignored. diff --git a/docs/note-frontmatter.md b/docs/note-frontmatter.md new file mode 100644 index 0000000..5e295f0 --- /dev/null +++ b/docs/note-frontmatter.md @@ -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.}}`, e.g. `{{metadata.description}}`. The keys are normalized to lower case.