Index notes content

pull/6/head
Mickaël Menu 3 years ago
parent b4f2c841eb
commit 0573852529
No known key found for this signature in database
GPG Key ID: 53D73664CD359895

@ -9,7 +9,7 @@ import (
// DB holds the connections to a SQLite database. // DB holds the connections to a SQLite database.
type DB struct { type DB struct {
db *sql.DB *sql.DB
} }
// Open creates a new DB instance for the SQLite database at the given path. // Open creates a new DB instance for the SQLite database at the given path.
@ -23,7 +23,7 @@ func Open(path string) (*DB, error) {
// Close terminates the connections to the SQLite database. // Close terminates the connections to the SQLite database.
func (db *DB) Close() error { func (db *DB) Close() error {
err := db.db.Close() err := db.Close()
return errors.Wrap(err, "failed to close the database") return errors.Wrap(err, "failed to close the database")
} }
@ -31,7 +31,7 @@ func (db *DB) Close() error {
func (db *DB) Migrate() error { func (db *DB) Migrate() error {
wrap := errors.Wrapper("database migration failed") wrap := errors.Wrapper("database migration failed")
tx, err := db.db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return wrap(err) return wrap(err)
} }
@ -51,18 +51,18 @@ func (db *DB) Migrate() error {
filename TEXT NOT NULL, filename TEXT NOT NULL,
dir TEXT NOT NULL, dir TEXT NOT NULL,
title TEXT DEFAULT('') NOT NULL, title TEXT DEFAULT('') NOT NULL,
content TEXT DEFAULT('') NOT NULL, body TEXT DEFAULT('') NOT NULL,
word_count INTEGER DEFAULT(0) NOT NULL, word_count INTEGER DEFAULT(0) NOT NULL,
checksum TEXT NOT NULL, checksum TEXT NOT NULL,
created TEXT DEFAULT(CURRENT_TIMESTAMP) NOT NULL, created DATETIME DEFAULT(CURRENT_TIMESTAMP) NOT NULL,
modified TEXT DEFAULT(CURRENT_TIMESTAMP) NOT NULL, modified DATETIME DEFAULT(CURRENT_TIMESTAMP) NOT NULL,
UNIQUE(filename, dir) UNIQUE(filename, dir)
) )
`, `,
`CREATE INDEX IF NOT EXISTS notes_checksum_idx ON notes(checksum)`, `CREATE INDEX IF NOT EXISTS notes_checksum_idx ON notes(checksum)`,
` `
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
title, content, title, body,
content = notes, content = notes,
content_rowid = id, content_rowid = id,
tokenize = 'porter unicode61 remove_diacritics 1' tokenize = 'porter unicode61 remove_diacritics 1'
@ -71,18 +71,18 @@ func (db *DB) Migrate() error {
// Triggers to keep the FTS index up to date. // Triggers to keep the FTS index up to date.
` `
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, content) VALUES (new.id, new.title, new.content); INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END END
`, `,
` `
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content); INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
END END
`, `,
` `
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content); INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
INSERT INTO notes_fts(rowid, title, content) VALUES (new.id, new.title, new.content); INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END END
`, `,
`PRAGMA user_version = 1`, `PRAGMA user_version = 1`,

@ -0,0 +1,123 @@
package sqlite
import (
"database/sql"
"path/filepath"
"time"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util"
)
type Indexer struct {
tx *sql.Tx
root string
logger util.Logger
// Prepared SQL statements
indexedStmt *sql.Stmt
addStmt *sql.Stmt
updateStmt *sql.Stmt
removeStmt *sql.Stmt
}
func NewIndexer(tx *sql.Tx, root string, logger util.Logger) (*Indexer, error) {
indexedStmt, err := tx.Prepare("SELECT filename, dir, modified from notes")
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 &Indexer{
tx: tx,
root: root,
logger: logger,
indexedStmt: indexedStmt,
addStmt: addStmt,
updateStmt: updateStmt,
removeStmt: removeStmt,
}, nil
}
func (i *Indexer) Indexed() (<-chan zk.FileMetadata, error) {
rows, err := i.indexedStmt.Query()
if err != nil {
return nil, err
}
c := make(chan zk.FileMetadata)
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 <- zk.FileMetadata{
Path: zk.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 *Indexer) Add(metadata zk.NoteMetadata) 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 *Indexer) Update(metadata zk.NoteMetadata) 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 *Indexer) Remove(path zk.Path) error {
_, err := i.updateStmt.Exec(path.Filename, path.Dir)
return err
}

@ -1,9 +1,6 @@
package cmd package cmd
import ( import (
"log"
"os"
"github.com/mickael-menu/zk/adapter/handlebars" "github.com/mickael-menu/zk/adapter/handlebars"
"github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/adapter/sqlite"
"github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/core/zk"
@ -21,7 +18,7 @@ func NewContainer() *Container {
date := date.NewFrozenNow() date := date.NewFrozenNow()
return &Container{ return &Container{
Logger: log.New(os.Stderr, "zk: warning: ", 0), Logger: util.NewStdLogger("zk: ", 0),
// zk is short-lived, so we freeze the current date to use the same // zk is short-lived, so we freeze the current date to use the same
// date for any rendering during the execution. // date for any rendering during the execution.
Date: &date, Date: &date,

@ -0,0 +1,43 @@
package cmd
import (
"github.com/mickael-menu/zk/adapter/sqlite"
"github.com/mickael-menu/zk/core/zk"
)
// Index indexes the content of all the notes in the slip box.
type Index struct {
Directory string `arg optional type:"path" default:"." help:"Directory containing the notes to index"`
}
func (cmd *Index) Run(container *Container) error {
z, err := zk.Open(".")
if err != nil {
return err
}
dir, err := z.RequireDirAt(cmd.Directory)
if err != nil {
return err
}
db, err := container.Database(z)
if err != nil {
return err
}
tx, err := db.Begin()
defer tx.Rollback()
if err != nil {
return err
}
indexer, err := sqlite.NewIndexer(tx, z.Path, container.Logger)
if err != nil {
return err
}
err = zk.Index(*dir, indexer, container.Logger)
if err != nil {
return err
}
return tx.Commit()
}

@ -1,8 +0,0 @@
package note
// Metadata holds information about a particular note.
type Metadata struct {
Title string
Content string
WordCount int
}

@ -31,7 +31,7 @@ func (d Dir) Walk(logger util.Logger) <-chan FileMetadata {
go func() { go func() {
defer close(c) defer close(c)
err := filepath.Walk(d.Path, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(d.Path, func(abs string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
@ -50,7 +50,7 @@ func (d Dir) Walk(logger util.Logger) <-chan FileMetadata {
return nil return nil
} }
path, err := filepath.Rel(d.Path, path) path, err := filepath.Rel(d.Path, abs)
if err != nil { if err != nil {
logger.Println(err) logger.Println(err)
return nil return nil
@ -65,6 +65,7 @@ func (d Dir) Walk(logger util.Logger) <-chan FileMetadata {
Path: Path{ Path: Path{
Dir: filepath.Join(d.Name, curDir), Dir: filepath.Join(d.Name, curDir),
Filename: filename, Filename: filename,
Abs: abs,
}, },
Modified: info.ModTime(), Modified: info.ModTime(),
} }

@ -17,27 +17,27 @@ func TestWalkRootDir(t *testing.T) {
res := toSlice(dir.Walk(&util.NullLogger)) res := toSlice(dir.Walk(&util.NullLogger))
assert.Equal(t, res, []FileMetadata{ assert.Equal(t, res, []FileMetadata{
{ {
Path: Path{Dir: "", Filename: "a.md"}, Path: Path{Dir: "", Filename: "a.md", Abs: filepath.Join(root, "a.md")},
Modified: date("2021-01-03T11:30:26.069257899+01:00"), Modified: date("2021-01-03T11:30:26.069257899+01:00"),
}, },
{ {
Path: Path{Dir: "", Filename: "b.md"}, Path: Path{Dir: "", Filename: "b.md", Abs: filepath.Join(root, "b.md")},
Modified: date("2021-01-03T11:30:27.545667767+01:00"), Modified: date("2021-01-03T11:30:27.545667767+01:00"),
}, },
{ {
Path: Path{Dir: "dir1", Filename: "a.md"}, Path: Path{Dir: "dir1", Filename: "a.md", Abs: filepath.Join(root, "dir1/a.md")},
Modified: date("2021-01-03T11:31:18.961628888+01:00"), Modified: date("2021-01-03T11:31:18.961628888+01:00"),
}, },
{ {
Path: Path{Dir: "dir1", Filename: "b.md"}, Path: Path{Dir: "dir1", Filename: "b.md", Abs: filepath.Join(root, "dir1/b.md")},
Modified: date("2021-01-03T11:31:24.692881103+01:00"), Modified: date("2021-01-03T11:31:24.692881103+01:00"),
}, },
{ {
Path: Path{Dir: "dir1/dir1", Filename: "a.md"}, Path: Path{Dir: "dir1/dir1", Filename: "a.md", Abs: filepath.Join(root, "dir1/dir1/a.md")},
Modified: date("2021-01-03T11:31:27.900472856+01:00"), Modified: date("2021-01-03T11:31:27.900472856+01:00"),
}, },
{ {
Path: Path{Dir: "dir2", Filename: "a.md"}, Path: Path{Dir: "dir2", Filename: "a.md", Abs: filepath.Join(root, "dir2/a.md")},
Modified: date("2021-01-03T11:31:51.001827456+01:00"), Modified: date("2021-01-03T11:31:51.001827456+01:00"),
}, },
}) })
@ -48,15 +48,15 @@ func TestWalkSubDir(t *testing.T) {
res := toSlice(dir.Walk(&util.NullLogger)) res := toSlice(dir.Walk(&util.NullLogger))
assert.Equal(t, res, []FileMetadata{ assert.Equal(t, res, []FileMetadata{
{ {
Path: Path{Dir: "dir1", Filename: "a.md"}, Path: Path{Dir: "dir1", Filename: "a.md", Abs: filepath.Join(root, "dir1/a.md")},
Modified: date("2021-01-03T11:31:18.961628888+01:00"), Modified: date("2021-01-03T11:31:18.961628888+01:00"),
}, },
{ {
Path: Path{Dir: "dir1", Filename: "b.md"}, Path: Path{Dir: "dir1", Filename: "b.md", Abs: filepath.Join(root, "dir1/b.md")},
Modified: date("2021-01-03T11:31:24.692881103+01:00"), Modified: date("2021-01-03T11:31:24.692881103+01:00"),
}, },
{ {
Path: Path{Dir: "dir1/dir1", Filename: "a.md"}, Path: Path{Dir: "dir1/dir1", Filename: "a.md", Abs: filepath.Join(root, "dir1/dir1/a.md")},
Modified: date("2021-01-03T11:31:27.900472856+01:00"), Modified: date("2021-01-03T11:31:27.900472856+01:00"),
}, },
}) })
@ -67,7 +67,7 @@ func TestWalkSubSubDir(t *testing.T) {
res := toSlice(dir.Walk(&util.NullLogger)) res := toSlice(dir.Walk(&util.NullLogger))
assert.Equal(t, res, []FileMetadata{ assert.Equal(t, res, []FileMetadata{
{ {
Path: Path{Dir: "dir1/dir1", Filename: "a.md"}, Path: Path{Dir: "dir1/dir1", Filename: "a.md", Abs: filepath.Join(root, "dir1/dir1/a.md")},
Modified: date("2021-01-03T11:31:27.900472856+01:00"), Modified: date("2021-01-03T11:31:27.900472856+01:00"),
}, },
}) })

@ -0,0 +1,93 @@
package zk
import (
"crypto/sha256"
"fmt"
"io/ioutil"
"strings"
"time"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/errors"
"gopkg.in/djherbis/times.v1"
)
// NoteMetadata holds information about a particular note.
type NoteMetadata struct {
Path Path
Title string
Body string
WordCount int
Created time.Time
Modified time.Time
Checksum string
}
type Indexer interface {
Indexed() (<-chan FileMetadata, error)
Add(metadata NoteMetadata) error
Update(metadata NoteMetadata) error
Remove(path Path) error
}
// Index indexes the content of the notes in the given directory.
func Index(dir Dir, indexer Indexer, logger util.Logger) error {
wrap := errors.Wrapper("indexation failed")
source := dir.Walk(logger)
target, err := indexer.Indexed()
if err != nil {
return wrap(err)
}
return Diff(source, target, func(change DiffChange) error {
switch change.Kind {
case DiffAdded:
metadata, err := noteMetadata(change.Path)
if err == nil {
err = indexer.Add(metadata)
}
logger.Err(err)
case DiffModified:
metadata, err := noteMetadata(change.Path)
if err == nil {
err = indexer.Update(metadata)
}
logger.Err(err)
case DiffRemoved:
indexer.Remove(change.Path)
}
return nil
})
}
func noteMetadata(path Path) (NoteMetadata, error) {
metadata := NoteMetadata{
Path: path,
}
content, err := ioutil.ReadFile(path.Abs)
if err != nil {
return metadata, err
}
contentStr := string(content)
metadata.Body = contentStr
metadata.WordCount = len(strings.Fields(contentStr))
metadata.Checksum = fmt.Sprintf("%x", sha256.Sum256(content))
times, err := times.Stat(path.Abs)
if err != nil {
return metadata, err
}
metadata.Modified = times.ModTime()
if times.HasBirthTime() {
metadata.Created = times.BirthTime()
} else {
metadata.Created = time.Now()
}
return metadata, nil
}

@ -28,6 +28,7 @@ type Zk struct {
type Path struct { type Path struct {
Dir string Dir string
Filename string Filename string
Abs string
} }
// Open locates a slip box at the given path and parses its configuration. // Open locates a slip box at the given path and parses its configuration.

@ -11,5 +11,6 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/lestrrat-go/strftime v1.0.3 github.com/lestrrat-go/strftime v1.0.3
github.com/mattn/go-sqlite3 v1.14.6 github.com/mattn/go-sqlite3 v1.14.6
gopkg.in/djherbis/times.v1 v1.2.0
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
) )

@ -70,5 +70,7 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/djherbis/times.v1 v1.2.0 h1:UCvDKl1L/fmBygl2Y7hubXCnY7t4Yj46ZrBFNUipFbM=
gopkg.in/djherbis/times.v1 v1.2.0/go.mod h1:AQlg6unIsrsCEdQYhTzERy542dz6SFdQFZFv6mUY0P8=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

@ -9,6 +9,7 @@ var Version = "dev"
var Build = "dev" var Build = "dev"
var cli struct { 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"` Init cmd.Init `cmd help:"Create a slip box in the given directory"`
New cmd.New `cmd help:"Create a new note in the given slip box directory"` New cmd.New `cmd help:"Create a new note in the given slip box directory"`
Version kong.VersionFlag `help:"Print zk version"` Version kong.VersionFlag `help:"Print zk version"`

@ -1,10 +1,16 @@
package util package util
import (
"log"
"os"
)
// Logger can be used to report logging messages. // Logger can be used to report logging messages.
// The native log.Logger type implements this interface. // The native log.Logger type implements this interface.
type Logger interface { type Logger interface {
Printf(format string, v ...interface{}) Printf(format string, v ...interface{})
Println(v ...interface{}) Println(v ...interface{})
Err(error)
} }
// NullLogger is a logger ignoring any input. // NullLogger is a logger ignoring any input.
@ -15,3 +21,20 @@ type nullLogger struct{}
func (n *nullLogger) Printf(format string, v ...interface{}) {} func (n *nullLogger) Printf(format string, v ...interface{}) {}
func (n *nullLogger) Println(v ...interface{}) {} func (n *nullLogger) Println(v ...interface{}) {}
func (n *nullLogger) Err(err error) {}
// StdLogger is a logger using the standard logger.
type StdLogger struct {
*log.Logger
}
func NewStdLogger(prefix string, flags int) StdLogger {
return StdLogger{log.New(os.Stderr, prefix, flags)}
}
func (l StdLogger) Err(err error) {
if err != nil {
l.Printf("warning: %v", err)
}
}

Loading…
Cancel
Save