From 16fea38764ed5238fc4d845463606e87e3545f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Wed, 13 Jan 2021 22:06:05 +0100 Subject: [PATCH] Sort list matches --- adapter/sqlite/note_dao.go | 41 ++++++++++++--- adapter/sqlite/note_dao_test.go | 88 ++++++++++++++++++++++++++++----- cmd/list.go | 73 +++++++++++++++++++++++++++ core/note/list.go | 23 +++++++++ 4 files changed, 207 insertions(+), 18 deletions(-) diff --git a/adapter/sqlite/note_dao.go b/adapter/sqlite/note_dao.go index fa656e5..50cc1d5 100644 --- a/adapter/sqlite/note_dao.go +++ b/adapter/sqlite/note_dao.go @@ -149,8 +149,8 @@ func (d *NoteDAO) exists(path string) (bool, error) { func (d *NoteDAO) Find(opts note.FinderOpts, callback func(note.Match) error) (int, error) { rows, err := func() (*sql.Rows, error) { snippetCol := `""` - orderTerm := `n.title ASC` whereExprs := make([]string, 0) + orderTerms := make([]string, 0) args := make([]interface{}, 0) for _, filter := range opts.Filters { @@ -158,7 +158,7 @@ func (d *NoteDAO) Find(opts note.FinderOpts, callback func(note.Match) error) (i case note.MatchFilter: snippetCol = `snippet(notes_fts, 2, '', '', '…', 20) as snippet` - orderTerm = `bm25(notes_fts, 1000.0, 500.0, 1.0)` + orderTerms = append(orderTerms, `bm25(notes_fts, 1000.0, 500.0, 1.0)`) whereExprs = append(whereExprs, "notes_fts MATCH ?") args = append(args, fts5.ConvertQuery(string(filter))) @@ -197,10 +197,15 @@ func (d *NoteDAO) Find(opts note.FinderOpts, callback func(note.Match) error) (i args = append(args, filter.Date) default: - panic("unknown filter type") + panic(fmt.Sprintf("%v: unknown filter type", filter)) } } + for _, sorter := range opts.Sorters { + orderTerms = append(orderTerms, orderTerm(sorter)) + } + orderTerms = append(orderTerms, `n.title ASC`) + query := "SELECT n.id, n.path, n.title, n.body, n.word_count, n.created, n.modified, n.checksum, " + snippetCol query += ` @@ -212,7 +217,7 @@ ON n.id = notes_fts.rowid` query += "\nWHERE " + strings.Join(whereExprs, "\nAND ") } - query += "\nORDER BY " + orderTerm + query += "\nORDER BY " + strings.Join(orderTerms, ", ") if opts.Limit > 0 { query += fmt.Sprintf("\nLIMIT %d", opts.Limit) @@ -267,7 +272,7 @@ func dateField(filter note.DateFilter) string { case note.DateModified: return "modified" default: - panic("unknown DateFilter field") + panic(fmt.Sprintf("%v: unknown note.DateField", filter.Field)) } } @@ -280,6 +285,30 @@ func dateDirection(filter note.DateFilter) (op string, ignoreTime bool) { case note.DateAfter: return ">=", false default: - panic("unknown DateFilter direction") + panic(fmt.Sprintf("%v: unknown note.DateDirection", filter.Direction)) + } +} + +func orderTerm(sorter note.Sorter) string { + order := " ASC" + if !sorter.Ascending { + order = " DESC" + } + + switch sorter.Term { + case note.SortCreated: + return "n.created" + order + case note.SortModified: + return "n.modified" + order + case note.SortPath: + return "n.path" + order + case note.SortRandom: + return "RANDOM()" + case note.SortTitle: + return "n.title" + order + case note.SortWordCount: + return "n.word_count" + order + default: + panic(fmt.Sprintf("%v: unknown note.SortTerm", sorter.Term)) } } diff --git a/adapter/sqlite/note_dao_test.go b/adapter/sqlite/note_dao_test.go index 3d9c7b1..df9b3b4 100644 --- a/adapter/sqlite/note_dao_test.go +++ b/adapter/sqlite/note_dao_test.go @@ -189,18 +189,6 @@ func TestNoteDAOFindMatch(t *testing.T) { Checksum: "qwfpgj", }, }, - { - Snippet: "A second daily note", - Metadata: note.Metadata{ - Path: "log/2021-01-04.md", - Title: "January 4, 2021", - Body: "A second daily note", - WordCount: 4, - 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", - }, - }, { Snippet: "A third daily note", Metadata: note.Metadata{ @@ -213,6 +201,18 @@ func TestNoteDAOFindMatch(t *testing.T) { Checksum: "earkte", }, }, + { + Snippet: "A second daily note", + Metadata: note.Metadata{ + Path: "log/2021-01-04.md", + Title: "January 4, 2021", + Body: "A second daily note", + WordCount: 4, + 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", + }, + }, }, ) } @@ -343,6 +343,70 @@ func TestNoteDAOFindModifiedAfter(t *testing.T) { ) } +func TestNoteDAOFindSortCreated(t *testing.T) { + testNoteDAOFindSort(t, note.SortCreated, true, []string{ + "ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md", + "log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md", + }) + testNoteDAOFindSort(t, note.SortCreated, false, []string{ + "log/2021-02-04.md", "log/2021-01-04.md", "log/2021-01-03.md", + "f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md", + }) +} + +func TestNoteDAOFindSortModified(t *testing.T) { + testNoteDAOFindSort(t, note.SortModified, true, []string{ + "ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md", + "log/2021-02-04.md", "log/2021-01-03.md", "log/2021-01-04.md", + }) + testNoteDAOFindSort(t, note.SortModified, false, []string{ + "log/2021-01-04.md", "log/2021-01-03.md", "log/2021-02-04.md", + "f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md", + }) +} + +func TestNoteDAOFindSortPath(t *testing.T) { + testNoteDAOFindSort(t, note.SortPath, true, []string{ + "f39c8.md", "index.md", "log/2021-01-03.md", "log/2021-01-04.md", + "log/2021-02-04.md", "ref/test/a.md", "ref/test/b.md", + }) + testNoteDAOFindSort(t, note.SortPath, false, []string{ + "ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md", + "log/2021-01-04.md", "log/2021-01-03.md", "index.md", "f39c8.md", + }) +} + +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", + }) + 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", + }) +} + +func TestNoteDAOFindSortWordCount(t *testing.T) { + testNoteDAOFindSort(t, note.SortWordCount, true, []string{ + "log/2021-01-03.md", "log/2021-02-04.md", "index.md", + "log/2021-01-04.md", "f39c8.md", "ref/test/a.md", "ref/test/b.md", + }) + testNoteDAOFindSort(t, note.SortWordCount, false, []string{ + "ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md", + "index.md", "log/2021-01-04.md", "log/2021-01-03.md", + }) +} + +func testNoteDAOFindSort(t *testing.T, term note.SortTerm, ascending bool, expected []string) { + testNoteDAOFindPaths(t, + note.FinderOpts{ + Sorters: []note.Sorter{{Term: term, Ascending: ascending}}, + }, + expected, + ) +} + func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { actual := make([]string, 0) diff --git a/cmd/list.go b/cmd/list.go index 1cdc27e..7dd82c6 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -2,7 +2,9 @@ package cmd import ( "fmt" + "strings" "time" + "unicode/utf8" "github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/core/note" @@ -25,6 +27,7 @@ type List struct { ModifiedBefore string `help:"Show only the notes modified before the given date" placeholder:"DATE"` ModifiedAfter string `help:"Show only the notes modified after the given date" placeholder:"DATE"` Exclude []string `help:"Excludes notes matching the given file path pattern from the list" placeholder:"GLOB"` + Sort []string `help:"Sort the notes by the given criterion" short:"s" placeholder:"CRITERION"` } func (cmd *List) Run(container *Container) error { @@ -150,10 +153,16 @@ func (cmd *List) ListOpts(zk *zk.Zk) (*note.ListOpts, error) { }) } + sorters, err := sorters(cmd.Sort) + if err != nil { + return nil, err + } + return ¬e.ListOpts{ Format: opt.NewNotEmptyString(cmd.Format), FinderOpts: note.FinderOpts{ Filters: filters, + Sorters: sorters, Limit: cmd.Limit, }, }, nil @@ -179,3 +188,67 @@ func parseDate(date string) (time.Time, error) { // FIXME: support years return naturaldate.Parse(date, time.Now().UTC(), naturaldate.WithDirection(naturaldate.Past)) } + +func sorters(terms []string) ([]note.Sorter, error) { + sorters := make([]note.Sorter, 0) + for _, term := range terms { + orderSymbol, _ := utf8.DecodeLastRuneInString(term) + term = strings.TrimRight(term, "+-") + + sorter, err := sorter(term) + if err != nil { + return sorters, err + } + + switch orderSymbol { + case '+': + sorter.Ascending = true + case '-': + sorter.Ascending = false + } + + sorters = append(sorters, sorter) + } + + return sorters, nil +} + +func sorter(term string) (sorter note.Sorter, err error) { + switch { + case strings.HasPrefix("created", term): + sorter = note.Sorter{Term: note.SortCreated, Ascending: false} + case strings.HasPrefix("modified", term): + sorter = note.Sorter{Term: note.SortModified, Ascending: false} + case strings.HasPrefix("path", term): + sorter = note.Sorter{Term: note.SortPath, Ascending: true} + case strings.HasPrefix("title", term): + sorter = note.Sorter{Term: note.SortTitle, Ascending: true} + case strings.HasPrefix("random", term): + sorter = note.Sorter{Term: note.SortRandom, Ascending: true} + case strings.HasPrefix("word-count", term): + sorter = note.Sorter{Term: note.SortWordCount, Ascending: true} + default: + err = fmt.Errorf("%s: unknown sorting term", term) + } + return +} + +func sortAscending(symbol rune, term string) bool { + + switch term { + case "created": + return false + case "modified": + return false + case "path": + return true + case "title": + return true + case "random": + return true + case "word-count": + return true + } + + return true +} diff --git a/core/note/list.go b/core/note/list.go index ef477bd..65b5d95 100644 --- a/core/note/list.go +++ b/core/note/list.go @@ -43,6 +43,28 @@ const ( DateModified ) +type Sorter struct { + Term SortTerm + Ascending bool +} + +type SortTerm int + +const ( + // Sort by creation date. + SortCreated SortTerm = iota + 1 + // Sort by modification date. + SortModified + // Sort by the file paths. + SortPath + // Sort randomly. + SortRandom + // Sort by the note titles. + SortTitle + // Sort by the number of words in the note bodies. + SortWordCount +) + // Match holds information about a note matching the list filters. type Match struct { // Snippet is an excerpt of the note. @@ -65,6 +87,7 @@ type Finder interface { type FinderOpts struct { Filters []Filter + Sorters []Sorter Limit int }