diff --git a/adapter/sqlite/indexer.go b/adapter/sqlite/indexer.go deleted file mode 100644 index 495f1d3..0000000 --- a/adapter/sqlite/indexer.go +++ /dev/null @@ -1,129 +0,0 @@ -package sqlite - -import ( - "database/sql" - "path/filepath" - "time" - - "github.com/mickael-menu/zk/core/file" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util" -) - -// NoteIndexer retrieves and stores notes indexation in the SQLite database. -// It implements the Core port note.Indexer. -type NoteIndexer struct { - tx Transaction - root string - logger util.Logger - - // Prepared SQL statements - indexedStmt *sql.Stmt - addStmt *sql.Stmt - updateStmt *sql.Stmt - removeStmt *sql.Stmt -} - -func NewNoteIndexer(tx Transaction, root string, logger util.Logger) (*NoteIndexer, error) { - indexedStmt, err := tx.Prepare(` - SELECT filename, dir, modified from notes - ORDER BY dir, filename ASC - `) - if err != nil { - return nil, err - } - - addStmt, err := tx.Prepare(` - INSERT INTO notes (filename, dir, title, body, word_count, checksum, created, modified) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `) - if err != nil { - return nil, err - } - - updateStmt, err := tx.Prepare(` - UPDATE notes - SET title = ?, body = ?, word_count = ?, checksum = ?, modified = ? - WHERE filename = ? AND dir = ? - `) - if err != nil { - return nil, err - } - - removeStmt, err := tx.Prepare(` - DELETE FROM notes - WHERE filename = ? AND dir = ? - `) - if err != nil { - return nil, err - } - - return &NoteIndexer{ - tx: tx, - root: root, - logger: logger, - indexedStmt: indexedStmt, - addStmt: addStmt, - updateStmt: updateStmt, - removeStmt: removeStmt, - }, nil -} - -func (i *NoteIndexer) Indexed() (<-chan file.Metadata, error) { - rows, err := i.indexedStmt.Query() - if err != nil { - return nil, err - } - - c := make(chan file.Metadata) - go func() { - defer close(c) - defer rows.Close() - var ( - filename, dir string - modified time.Time - ) - - for rows.Next() { - err := rows.Scan(&filename, &dir, &modified) - if err != nil { - i.logger.Err(err) - } - - c <- file.Metadata{ - Path: file.Path{Dir: dir, Filename: filename, Abs: filepath.Join(i.root, dir, filename)}, - Modified: modified, - } - } - - err = rows.Err() - if err != nil { - i.logger.Err(err) - } - }() - - return c, nil -} - -func (i *NoteIndexer) Add(metadata note.Metadata) error { - _, err := i.addStmt.Exec( - metadata.Path.Filename, metadata.Path.Dir, metadata.Title, - metadata.Body, metadata.WordCount, metadata.Checksum, - metadata.Created, metadata.Modified, - ) - return err -} - -func (i *NoteIndexer) Update(metadata note.Metadata) error { - _, err := i.updateStmt.Exec( - metadata.Title, metadata.Body, metadata.WordCount, - metadata.Checksum, metadata.Modified, - metadata.Path.Filename, metadata.Path.Dir, - ) - return err -} - -func (i *NoteIndexer) Remove(path file.Path) error { - _, err := i.updateStmt.Exec(path.Filename, path.Dir) - return err -} diff --git a/adapter/sqlite/note.go b/adapter/sqlite/note.go new file mode 100644 index 0000000..ab7dbfd --- /dev/null +++ b/adapter/sqlite/note.go @@ -0,0 +1,193 @@ +package sqlite + +import ( + "database/sql" + "path/filepath" + "time" + + "github.com/mickael-menu/zk/core/file" + "github.com/mickael-menu/zk/core/note" + "github.com/mickael-menu/zk/util" +) + +// NoteDAO persists notes in the SQLite database. +// It implements the core ports note.Indexer and note.Finder. +type NoteDAO struct { + tx Transaction + root string + logger util.Logger + + // Prepared SQL statements + indexedStmt *sql.Stmt + addStmt *sql.Stmt + updateStmt *sql.Stmt + removeStmt *sql.Stmt +} + +func NewNoteDAO(tx Transaction, root string, logger util.Logger) (*NoteDAO, error) { + indexedStmt, err := tx.Prepare(` + SELECT dir, filename, modified from notes + ORDER BY dir, filename ASC + `) + if err != nil { + return nil, err + } + + addStmt, err := tx.Prepare(` + INSERT INTO notes (dir, filename, title, body, word_count, checksum, created, modified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return nil, err + } + + updateStmt, err := tx.Prepare(` + UPDATE notes + SET title = ?, body = ?, word_count = ?, checksum = ?, modified = ? + WHERE dir = ? AND filename = ?`) + if err != nil { + return nil, err + } + + removeStmt, err := tx.Prepare(` + DELETE FROM notes + WHERE dir = ? AND filename = ? + `) + if err != nil { + return nil, err + } + + return &NoteDAO{ + tx: tx, + root: root, + logger: logger, + indexedStmt: indexedStmt, + addStmt: addStmt, + updateStmt: updateStmt, + removeStmt: removeStmt, + }, nil +} + +func (d *NoteDAO) Indexed() (<-chan file.Metadata, error) { + rows, err := d.indexedStmt.Query() + if err != nil { + return nil, err + } + + c := make(chan file.Metadata) + go func() { + defer close(c) + defer rows.Close() + var ( + dir, filename string + modified time.Time + ) + + for rows.Next() { + err := rows.Scan(&dir, &filename, &modified) + if err != nil { + d.logger.Err(err) + } + + c <- file.Metadata{ + Path: file.Path{Dir: dir, Filename: filename, Abs: filepath.Join(d.root, dir, filename)}, + Modified: modified, + } + } + + err = rows.Err() + if err != nil { + d.logger.Err(err) + } + }() + + return c, nil +} + +func (d *NoteDAO) Add(note note.Metadata) error { + _, err := d.addStmt.Exec( + note.Path.Dir, note.Path.Filename, note.Title, + note.Body, note.WordCount, note.Checksum, + note.Created, note.Modified, + ) + return err +} + +func (d *NoteDAO) Update(note note.Metadata) error { + _, err := d.updateStmt.Exec( + note.Title, note.Body, note.WordCount, + note.Checksum, note.Modified, + note.Path.Dir, note.Path.Filename, + ) + return err +} + +func (d *NoteDAO) Remove(path file.Path) error { + _, err := d.updateStmt.Exec(path.Dir, path.Filename) + return err +} + +func (d *NoteDAO) Find(callback func(note.Match) error, filters ...note.Filter) error { + rows, err := func() (*sql.Rows, error) { + if len(filters) == 0 { + return d.tx.Query(` + SELECT id, dir, filename, title, body, word_count, created, modified, + checksum, "" as snippet from notes + ORDER BY title ASC + `) + } else { + filter := filters[0].(note.QueryFilter) + return d.tx.Query(` + SELECT n.id, n.dir, n.filename, n.title, n.body, n.word_count, + n.created, n.modified, n.checksum, + snippet(notes_fts, -1, '\033[31m', '\033[0m', '…', 20) as snippet + FROM notes n + JOIN notes_fts + ON n.id = notes_fts.rowid + WHERE notes_fts MATCH ? + ORDER BY bm25(notes_fts, 1000.0, 1.0) + --- ORDER BY rank + `, filter) + } + }() + + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + id, wordCount int + title, body, snippet string + dir, filename, checksum string + created, modified time.Time + ) + + err := rows.Scan(&id, &dir, &filename, &title, &body, &wordCount, &created, &modified, &checksum, &snippet) + if err != nil { + d.logger.Err(err) + continue + } + + callback(note.Match{ + ID: id, + Snippet: snippet, + Metadata: note.Metadata{ + Path: file.Path{ + Dir: dir, + Filename: filename, + Abs: filepath.Join(d.root, dir, filename), + }, + Title: title, + Body: body, + WordCount: wordCount, + Created: created, + Modified: modified, + Checksum: checksum, + }, + }) + } + + return nil +} diff --git a/cmd/index.go b/cmd/index.go index 8965417..b206de8 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -28,7 +28,7 @@ func (cmd *Index) Run(container *Container) error { } return db.WithTransaction(func(tx sqlite.Transaction) error { - indexer, err := sqlite.NewNoteIndexer(tx, zk.Path, container.Logger) + indexer, err := sqlite.NewNoteDAO(tx, zk.Path, container.Logger) if err != nil { return err } diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..fdfa9ad --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "github.com/mickael-menu/zk/adapter/sqlite" + "github.com/mickael-menu/zk/core/note" + "github.com/mickael-menu/zk/core/zk" +) + +// List displays notes matching a set of criteria. +type List struct { + Query string `arg optional help:"Terms to search for in the notes" placeholder:"TERMS"` +} + +func (cmd *List) Run(container *Container) error { + zk, err := zk.Open(".") + if err != nil { + return err + } + + db, err := container.Database(zk.DBPath()) + if err != nil { + return err + } + + return db.WithTransaction(func(tx sqlite.Transaction) error { + notes, err := sqlite.NewNoteDAO(tx, zk.Path, container.Logger) + if err != nil { + return err + } + + filters := make([]note.Filter, 0) + if cmd.Query != "" { + filters = append(filters, note.QueryFilter(cmd.Query)) + } + + return note.List(notes, filters...) + }) +} diff --git a/core/note/list.go b/core/note/list.go new file mode 100644 index 0000000..dc0da65 --- /dev/null +++ b/core/note/list.go @@ -0,0 +1,30 @@ +package note + +import ( + "fmt" + "strings" +) + +type QueryFilter string + +type Match struct { + ID int + Snippet string + Metadata +} + +type Finder interface { + Find(callback func(Match) error, filters ...Filter) error +} + +func List(finder Finder, filters ...Filter) error { + return finder.Find(func(note Match) error { + fmt.Printf("%v\n", strings.ReplaceAll(note.Snippet, "\\033", "\033")) + return nil + }, filters...) +} + +// Filter is a sealed interface implemented by Finder filter criteria. +type Filter interface{ sealed() } + +func (f QueryFilter) sealed() {} diff --git a/main.go b/main.go index 114a88c..3ea2f44 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ var Build = "dev" var cli struct { Index cmd.Index `cmd help:"Index the notes in the given directory to be searchable"` Init cmd.Init `cmd help:"Create a slip box in the given directory"` + List cmd.List `cmd help:"List notes matching given criteria"` New cmd.New `cmd help:"Create a new note in the given slip box directory"` Version kong.VersionFlag `help:"Print zk version"` }