Add note links to the database

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

@ -71,7 +71,7 @@ func New(opts Opts) (*Fzf, error) {
"--no-hscroll", "--no-hscroll",
// Don't highlight search terms // Don't highlight search terms
"--color", "hl:-1,hl+:-1", "--color", "hl:-1,hl+:-1",
// "--preview-window", "noborder:wrap", "--preview-window", "wrap",
} }
if !opts.PreviewCmd.IsNull() { if !opts.PreviewCmd.IsNull() {
args = append(args, "--preview", opts.PreviewCmd.String()) args = append(args, "--preview", opts.PreviewCmd.String())

@ -7,6 +7,7 @@ import (
"github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util/opt" "github.com/mickael-menu/zk/util/opt"
strutil "github.com/mickael-menu/zk/util/strings"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta" meta "github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
@ -127,12 +128,13 @@ func parseLinks(root ast.Node, source []byte) ([]note.Link, error) {
err := ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { err := ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if link, ok := n.(*ast.Link); ok && entering { if link, ok := n.(*ast.Link); ok && entering {
target := string(link.Destination) href := string(link.Destination)
if target != "" { if href != "" {
links = append(links, note.Link{ links = append(links, note.Link{
Title: string(link.Text(source)), Title: string(link.Text(source)),
Target: target, Href: href,
Rels: strings.Fields(string(link.Title)), Rels: strings.Fields(string(link.Title)),
External: strutil.IsURL(href),
}) })
} }
} }

@ -165,38 +165,52 @@ func TestParseLinks(t *testing.T) {
test(` test(`
# Heading with a [link](heading) # Heading with a [link](heading)
Paragraph containing [multiple **links**](stripped-formatting) some [external like this one](http://example.com), and other [relative](../other). Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other).
A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2"). A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").
[External links](http://example.com) are marked [as such](ftp://domain).
`, []note.Link{ `, []note.Link{
{ {
Title: "link", Title: "link",
Target: "heading", Href: "heading",
Rels: []string{}, Rels: []string{},
External: false,
},
{
Title: "multiple links",
Href: "stripped-formatting",
Rels: []string{},
External: false,
}, },
{ {
Title: "multiple links", Title: "relative",
Target: "stripped-formatting", Href: "../other",
Rels: []string{}, Rels: []string{},
External: false,
}, },
{ {
Title: "external like this one", Title: "one relation",
Target: "http://example.com", Href: "one",
Rels: []string{}, Rels: []string{"rel-1"},
External: false,
}, },
{ {
Title: "relative", Title: "several relations",
Target: "../other", Href: "several",
Rels: []string{}, Rels: []string{"rel-1", "rel-2"},
External: false,
}, },
{ {
Title: "one relation", Title: "External links",
Target: "one", Href: "http://example.com",
Rels: []string{"rel-1"}, Rels: []string{},
External: true,
}, },
{ {
Title: "several relations", Title: "as such",
Target: "several", Href: "ftp://domain",
Rels: []string{"rel-1", "rel-2"}, Rels: []string{},
External: true,
}, },
}) })
} }

@ -23,10 +23,20 @@ func OpenInMemory() (*DB, error) {
} }
func open(uri string) (*DB, error) { func open(uri string) (*DB, error) {
wrap := errors.Wrapper("failed to open the database")
db, err := sql.Open("sqlite3", uri) db, err := sql.Open("sqlite3", uri)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to open the database") return nil, wrap(err)
} }
// Make sure that CASCADE statements are properly applied by enabling
// foreign keys.
_, err = db.Exec("PRAGMA foreign_keys = ON")
if err != nil {
return nil, wrap(err)
}
return &DB{db}, nil return &DB{db}, nil
} }
@ -47,6 +57,7 @@ func (db *DB) Migrate() error {
if version == 0 { if version == 0 {
err = tx.ExecStmts([]string{ err = tx.ExecStmts([]string{
// Notes
`CREATE TABLE IF NOT EXISTS notes ( `CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
@ -63,6 +74,22 @@ func (db *DB) Migrate() error {
)`, )`,
`CREATE INDEX IF NOT EXISTS index_notes_checksum ON notes (checksum)`, `CREATE INDEX IF NOT EXISTS index_notes_checksum ON notes (checksum)`,
`CREATE INDEX IF NOT EXISTS index_notes_path ON notes (path)`, `CREATE INDEX IF NOT EXISTS index_notes_path ON notes (path)`,
// Links
`CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
source_id INTEGER NOT NULL REFERENCES notes(id)
ON DELETE CASCADE,
target_id INTEGER REFERENCES notes(id)
ON DELETE SET NULL,
title TEXT DEFAULT('') NOT NULL,
href TEXT NOT NULL,
external INT DEFAULT(0) NOT NULL,
rels TEXT DEFAULT('') NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS index_links_source_id_target_id ON links (source_id, target_id)`,
// FTS index
`CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( `CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
path, title, body, path, title, body,
content = notes, content = notes,

@ -0,0 +1,27 @@
- id: 1
source_id: 3
target_id: null
title: "Missing target"
href: "missing"
external: false
- id: 2
source_id: 1
target_id: 2
title: "An internal link"
href: "log/2021-01-04.md"
external: false
- id: 3
source_id: 1
target_id: null
title: "An external link"
href: "https://domain.com"
external: true
- id: 4
source_id: 4
target_id: 1
title: "Another link"
href: "log/2021-01-03.md"
external: false

@ -20,40 +20,84 @@ type NoteDAO struct {
logger util.Logger logger util.Logger
// Prepared SQL statements // Prepared SQL statements
indexedStmt *LazyStmt indexedStmt *LazyStmt
addStmt *LazyStmt addStmt *LazyStmt
updateStmt *LazyStmt updateStmt *LazyStmt
removeStmt *LazyStmt removeStmt *LazyStmt
existsStmt *LazyStmt findIdByPathStmt *LazyStmt
findIdByPathPrefixStmt *LazyStmt
addLinkStmt *LazyStmt
setLinksTargetStmt *LazyStmt
removeLinksStmt *LazyStmt
} }
// NewNoteDAO creates a new instance of a DAO working on the given database
// transaction.
func NewNoteDAO(tx Transaction, logger util.Logger) *NoteDAO { func NewNoteDAO(tx Transaction, logger util.Logger) *NoteDAO {
return &NoteDAO{ return &NoteDAO{
tx: tx, tx: tx,
logger: logger, logger: logger,
// Get file info about all indexed notes.
indexedStmt: tx.PrepareLazy(` indexedStmt: tx.PrepareLazy(`
SELECT path, modified from notes SELECT path, modified from notes
ORDER BY sortable_path ASC ORDER BY sortable_path ASC
`), `),
// Add a new note to the index.
addStmt: tx.PrepareLazy(` addStmt: tx.PrepareLazy(`
INSERT INTO notes (path, sortable_path, title, lead, body, raw_content, word_count, checksum, created, modified) INSERT INTO notes (path, sortable_path, title, lead, body, raw_content, word_count, checksum, created, modified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), `),
// Update the content of a note.
updateStmt: tx.PrepareLazy(` updateStmt: tx.PrepareLazy(`
UPDATE notes UPDATE notes
SET title = ?, lead = ?, body = ?, raw_content = ?, word_count = ?, checksum = ?, modified = ? SET title = ?, lead = ?, body = ?, raw_content = ?, word_count = ?, checksum = ?, modified = ?
WHERE path = ? WHERE path = ?
`), `),
// Remove a note.
removeStmt: tx.PrepareLazy(` removeStmt: tx.PrepareLazy(`
DELETE FROM notes DELETE FROM notes
WHERE id = ?
`),
// Find a note ID from its exact path.
findIdByPathStmt: tx.PrepareLazy(`
SELECT id FROM notes
WHERE path = ? WHERE path = ?
`), `),
existsStmt: tx.PrepareLazy(`
SELECT EXISTS (SELECT 1 FROM notes WHERE path = ?) // Find a note ID from a prefix of its path.
findIdByPathPrefixStmt: tx.PrepareLazy(`
SELECT id FROM notes
WHERE path LIKE ? || '%'
`),
// Add a new link.
addLinkStmt: tx.PrepareLazy(`
INSERT INTO links (source_id, target_id, title, href, external, rels)
VALUES (?, ?, ?, ?, ?, ?)
`),
// Set links matching a given href and missing a target ID to the given
// target ID.
setLinksTargetStmt: tx.PrepareLazy(`
UPDATE links
SET target_id = ?
WHERE target_id IS NULL AND external = 0 AND ? LIKE href || '%'
`),
// Remove all the outbound links of a note.
removeLinksStmt: tx.PrepareLazy(`
DELETE FROM links
WHERE source_id = ?
`), `),
} }
} }
// Indexed returns file info of all indexed notes.
func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) { func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) {
wrap := errors.Wrapper("failed to get indexed notes") wrap := errors.Wrapper("failed to get indexed notes")
@ -92,7 +136,10 @@ func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) {
return c, nil return c, nil
} }
func (d *NoteDAO) Add(note note.Metadata) error { // Add inserts a new note to the index.
func (d *NoteDAO) Add(note note.Metadata) (int64, error) {
wrap := errors.Wrapperf("%v: can't add note to the index", note.Path)
// For sortable_path, we replace in path / by the shortest non printable // For sortable_path, we replace in path / by the shortest non printable
// character available to make it sortable. Without this, sorting by the // character available to make it sortable. Without this, sorting by the
// path would be a lexicographical sort instead of being the same order // path would be a lexicographical sort instead of being the same order
@ -101,21 +148,32 @@ func (d *NoteDAO) Add(note note.Metadata) error {
// string. // string.
sortablePath := strings.ReplaceAll(note.Path, "/", "\x01") sortablePath := strings.ReplaceAll(note.Path, "/", "\x01")
_, err := d.addStmt.Exec( res, err := d.addStmt.Exec(
note.Path, sortablePath, note.Title, note.Lead, note.Body, note.RawContent, note.WordCount, note.Checksum, note.Path, sortablePath, note.Title, note.Lead, note.Body, note.RawContent, note.WordCount, note.Checksum,
note.Created, note.Modified, note.Created, note.Modified,
) )
return errors.Wrapf(err, "%v: can't add note to the index", note.Path) if err != nil {
return 0, wrap(err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, wrap(err)
}
err = d.addLinks(id, note)
return id, err
} }
// Update modifies an existing note.
func (d *NoteDAO) Update(note note.Metadata) error { func (d *NoteDAO) Update(note note.Metadata) error {
wrap := errors.Wrapperf("%v: failed to update note index", note.Path) wrap := errors.Wrapperf("%v: failed to update note index", note.Path)
exists, err := d.exists(note.Path) id, err := d.findIdByPath(note.Path)
if err != nil { if err != nil {
return wrap(err) return wrap(err)
} }
if !exists { if !id.Valid {
return wrap(errors.New("note not found in the index")) return wrap(errors.New("note not found in the index"))
} }
@ -123,37 +181,94 @@ func (d *NoteDAO) Update(note note.Metadata) error {
note.Title, note.Lead, note.Body, note.RawContent, note.WordCount, note.Checksum, note.Modified, note.Title, note.Lead, note.Body, note.RawContent, note.WordCount, note.Checksum, note.Modified,
note.Path, note.Path,
) )
return errors.Wrapf(err, "%v: failed to update note index", note.Path) if err != nil {
return wrap(err)
}
_, err = d.removeLinksStmt.Exec(id.Int64)
if err != nil {
return wrap(err)
}
err = d.addLinks(id.Int64, note)
return wrap(err)
}
// addLinks inserts all the outbound links of the given note.
func (d *NoteDAO) addLinks(id int64, note note.Metadata) error {
for _, link := range note.Links {
targetId, err := d.findIdByPathPrefix(link.Href)
if err != nil {
return err
}
_, err = d.addLinkStmt.Exec(id, targetId, link.Title, link.Href, link.External, joinLinkRels(link.Rels))
if err != nil {
return err
}
}
_, err := d.setLinksTargetStmt.Exec(id, note.Path)
return err
}
// joinLinkRels will concatenate a list of rels into a SQLite ready string.
// Each rel is delimited by \x01 for easy matching in queries.
func joinLinkRels(rels []string) string {
if len(rels) == 0 {
return ""
}
delimiter := "\x01"
return delimiter + strings.Join(rels, delimiter) + delimiter
} }
// Remove deletes the note with the given path from the index.
func (d *NoteDAO) Remove(path string) error { func (d *NoteDAO) Remove(path string) error {
wrap := errors.Wrapperf("%v: failed to remove note index", path) wrap := errors.Wrapperf("%v: failed to remove note index", path)
exists, err := d.exists(path) id, err := d.findIdByPath(path)
if err != nil { if err != nil {
return wrap(err) return wrap(err)
} }
if !exists { if !id.Valid {
return wrap(errors.New("note not found in the index")) return wrap(errors.New("note not found in the index"))
} }
_, err = d.removeStmt.Exec(path) _, err = d.removeStmt.Exec(id)
return wrap(err) return wrap(err)
} }
func (d *NoteDAO) exists(path string) (bool, error) { func (d *NoteDAO) findIdByPath(path string) (sql.NullInt64, error) {
row, err := d.existsStmt.QueryRow(path) row, err := d.findIdByPathStmt.QueryRow(path)
if err != nil { if err != nil {
return false, err return sql.NullInt64{}, err
} }
var exists bool return idForRow(row)
row.Scan(&exists) }
func (d *NoteDAO) findIdByPathPrefix(path string) (sql.NullInt64, error) {
row, err := d.findIdByPathPrefixStmt.QueryRow(path)
if err != nil { if err != nil {
return false, err return sql.NullInt64{}, err
}
return idForRow(row)
}
func idForRow(row *sql.Row) (sql.NullInt64, error) {
var id sql.NullInt64
err := row.Scan(&id)
switch {
case err == sql.ErrNoRows:
return id, nil
case err != nil:
return id, err
default:
return id, err
} }
return exists, nil
} }
// Find returns all the notes matching the given criteria.
func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) { func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
matches := make([]note.Match, 0) matches := make([]note.Match, 0)

@ -1,6 +1,7 @@
package sqlite package sqlite
import ( import (
"database/sql"
"fmt" "fmt"
"testing" "testing"
"time" "time"
@ -47,7 +48,8 @@ func TestNoteDAOIndexed(t *testing.T) {
Modified: time.Date(2019, 11, 12, 20, 34, 6, 0, time.UTC), Modified: time.Date(2019, 11, 12, 20, 34, 6, 0, time.UTC),
}, },
} { } {
assert.Nil(t, dao.Add(note)) _, err := dao.Add(note)
assert.Nil(t, err)
} }
// We check that the metadata are sorted by the path but not // We check that the metadata are sorted by the path but not
@ -101,7 +103,7 @@ func TestNoteDAOIndexed(t *testing.T) {
func TestNoteDAOAdd(t *testing.T) { func TestNoteDAOAdd(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
err := dao.Add(note.Metadata{ _, err := dao.Add(note.Metadata{
Path: "log/added.md", Path: "log/added.md",
Title: "Added note", Title: "Added note",
Lead: "Note", Lead: "Note",
@ -130,10 +132,103 @@ func TestNoteDAOAdd(t *testing.T) {
}) })
} }
func TestNoteDAOAddWithLinks(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
id, err := dao.Add(note.Metadata{
Path: "log/added.md",
Links: []note.Link{
{
Title: "Same dir",
Href: "log/2021-01-04",
Rels: []string{"rel-1", "rel-2"},
},
{
Title: "Relative",
Href: "f39c8",
},
{
Title: "Second is added",
Href: "f39c8",
Rels: []string{"second"},
},
{
Title: "Unknown",
Href: "unknown",
},
{
Title: "URL",
Href: "http://example.com",
External: true,
},
},
})
assert.Nil(t, err)
rows := queryLinkRows(t, tx, fmt.Sprintf("source_id = %d", id))
assert.Equal(t, rows, []linkRow{
{
SourceId: id,
TargetId: intPointer(2),
Title: "Same dir",
Href: "log/2021-01-04",
Rels: "\x01rel-1\x01rel-2\x01",
},
{
SourceId: id,
TargetId: intPointer(4),
Title: "Relative",
Href: "f39c8",
Rels: "",
},
{
SourceId: id,
TargetId: intPointer(4),
Title: "Second is added",
Href: "f39c8",
Rels: "\x01second\x01",
},
{
SourceId: id,
TargetId: nil,
Title: "Unknown",
Href: "unknown",
Rels: "",
},
{
SourceId: id,
TargetId: nil,
Title: "URL",
Href: "http://example.com",
External: true,
Rels: "",
},
})
})
}
func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
id, err := dao.Add(note.Metadata{
Path: "missing_target.md",
})
assert.Nil(t, err)
rows := queryLinkRows(t, tx, fmt.Sprintf("target_id = %d", id))
assert.Equal(t, rows, []linkRow{
{
SourceId: 3,
TargetId: &id,
Title: "Missing target",
Href: "missing",
},
})
})
}
// Check that we can't add a duplicate note with an existing path. // Check that we can't add a duplicate note with an existing path.
func TestNoteDAOAddExistingNote(t *testing.T) { func TestNoteDAOAddExistingNote(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
err := dao.Add(note.Metadata{Path: "ref/test/a.md"}) _, err := dao.Add(note.Metadata{Path: "ref/test/a.md"})
assert.Err(t, err, "ref/test/a.md: can't add note to the index: UNIQUE constraint failed: notes.path") assert.Err(t, err, "ref/test/a.md: can't add note to the index: UNIQUE constraint failed: notes.path")
}) })
} }
@ -178,10 +273,73 @@ func TestNoteDAOUpdateUnknown(t *testing.T) {
}) })
} }
func TestNoteDAOUpdateWithLinks(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
links := queryLinkRows(t, tx, "source_id = 1")
assert.Equal(t, links, []linkRow{
{
SourceId: 1,
TargetId: intPointer(2),
Title: "An internal link",
Href: "log/2021-01-04.md",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
External: true,
},
})
err := dao.Update(note.Metadata{
Path: "log/2021-01-03.md",
Links: []note.Link{
{
Title: "A new link",
Href: "index",
External: false,
Rels: []string{"rel"},
},
{
Title: "An external link",
Href: "https://domain.com",
External: true,
},
},
})
assert.Nil(t, err)
links = queryLinkRows(t, tx, "source_id = 1")
assert.Equal(t, links, []linkRow{
{
SourceId: 1,
TargetId: intPointer(3),
Title: "A new link",
Href: "index",
Rels: "\x01rel\x01",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
External: true,
},
})
})
}
func TestNoteDAORemove(t *testing.T) { func TestNoteDAORemove(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
err := dao.Remove("ref/test/a.md") _, err := queryNoteRow(tx, `path = "ref/test/a.md"`)
assert.Nil(t, err)
err = dao.Remove("ref/test/a.md")
assert.Nil(t, err) assert.Nil(t, err)
_, err = queryNoteRow(tx, `path = "ref/test/a.md"`)
assert.Equal(t, err, sql.ErrNoRows)
}) })
} }
@ -192,6 +350,26 @@ func TestNoteDAORemoveUnknown(t *testing.T) {
}) })
} }
// Also remove the outbound links, and set the target_id of inbound links to NULL.
func TestNoteDAORemoveCascadeLinks(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
links := queryLinkRows(t, tx, `source_id = 1`)
assert.Equal(t, len(links) > 0, true)
links = queryLinkRows(t, tx, `id = 4`)
assert.Equal(t, *links[0].TargetId, int64(1))
err := dao.Remove("log/2021-01-03.md")
assert.Nil(t, err)
links = queryLinkRows(t, tx, `source_id = 1`)
assert.Equal(t, len(links), 0)
links = queryLinkRows(t, tx, `id = 4`)
assert.Nil(t, links[0].TargetId)
})
}
func TestNoteDAOFindAll(t *testing.T) { func TestNoteDAOFindAll(t *testing.T) {
testNoteDAOFindPaths(t, note.FinderOpts{}, []string{ testNoteDAOFindPaths(t, note.FinderOpts{}, []string{
"ref/test/b.md", "ref/test/b.md",
@ -515,3 +693,37 @@ func queryNoteRow(tx Transaction, where string) (noteRow, error) {
`, where)).Scan(&row.Path, &row.Title, &row.Lead, &row.Body, &row.RawContent, &row.WordCount, &row.Checksum, &row.Created, &row.Modified) `, where)).Scan(&row.Path, &row.Title, &row.Lead, &row.Body, &row.RawContent, &row.WordCount, &row.Checksum, &row.Created, &row.Modified)
return row, err return row, err
} }
type linkRow struct {
SourceId int64
TargetId *int64
Href, Title, Rels string
External bool
}
func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow {
links := make([]linkRow, 0)
rows, err := tx.Query(fmt.Sprintf(`
SELECT source_id, target_id, title, href, external, rels
FROM links
WHERE %v
ORDER BY id
`, where))
assert.Nil(t, err)
for rows.Next() {
var row linkRow
err = rows.Scan(&row.SourceId, &row.TargetId, &row.Title, &row.Href, &row.External, &row.Rels)
assert.Nil(t, err)
links = append(links, row)
}
rows.Close()
assert.Nil(t, rows.Err())
return links
}
func intPointer(i int64) *int64 {
return &i
}

@ -13,9 +13,8 @@ import (
// Index indexes the content of all the notes in the slip box. // Index indexes the content of all the notes in the slip box.
type Index struct { type Index struct {
Directory string `arg optional type:"path" default:"." help:"Directory containing the notes to index"` Force bool `help:"Force indexing all the notes" short:"f"`
Force bool `help:"Force indexing all the notes" short:"f"` Quiet bool `help:"Do not print statistics nor progress" short:"q"`
Quiet bool `help:"Do not print statistics nor progress" short:"q"`
} }
func (cmd *Index) Run(container *Container) error { func (cmd *Index) Run(container *Container) error {
@ -24,48 +23,35 @@ func (cmd *Index) Run(container *Container) error {
return err return err
} }
dir, err := zk.RequireDirAt(cmd.Directory)
if err != nil {
return err
}
db, err := container.Database(zk.DBPath()) db, err := container.Database(zk.DBPath())
if err != nil { if err != nil {
return err return err
} }
var bar *progressbar.ProgressBar var bar = progressbar.NewOptions(-1,
if !cmd.Quiet { progressbar.OptionSetWriter(os.Stderr),
bar = progressbar.NewOptions(-1, progressbar.OptionThrottle(100*time.Millisecond),
progressbar.OptionSetWriter(os.Stderr), progressbar.OptionSpinnerType(14),
progressbar.OptionThrottle(100*time.Millisecond), )
progressbar.OptionSpinnerType(14),
)
}
var stats note.IndexingStats var stats note.IndexingStats
err = db.WithTransaction(func(tx sqlite.Transaction) error { err = db.WithTransaction(func(tx sqlite.Transaction) error {
notes := sqlite.NewNoteDAO(tx, container.Logger) notes := sqlite.NewNoteDAO(tx, container.Logger)
stats, err = note.Index( stats, err = note.Index(
*dir, zk,
cmd.Force, cmd.Force,
container.Parser(), container.Parser(),
notes, notes,
container.Logger, container.Logger,
func(change paths.DiffChange) { func(change paths.DiffChange) {
if bar != nil { bar.Add(1)
bar.Add(1) bar.Describe(change.String())
bar.Describe(change.String())
}
}, },
) )
return err return err
}) })
bar.Clear()
if bar != nil {
bar.Clear()
}
if err == nil && !cmd.Quiet { if err == nil && !cmd.Quiet {
fmt.Println(stats) fmt.Println(stats)

@ -16,6 +16,7 @@ import (
type List struct { type List struct {
Format string `help:"Pretty prints the list using the given format" short:"f" placeholder:"<template>"` Format string `help:"Pretty prints the list using the given format" short:"f" placeholder:"<template>"`
NoPager bool `help:"Do not pipe zk output into a pager" short:"P"` NoPager bool `help:"Do not pipe zk output into a pager" short:"P"`
Quiet bool `help:"Don't show anything besides the notes themselves" short:"q"`
Filtering `embed` Filtering `embed`
Sorting `embed` Sorting `embed`
} }
@ -74,7 +75,7 @@ func (cmd *List) Run(container *Container) error {
}) })
} }
if err == nil { if err == nil && !cmd.Quiet {
fmt.Printf("\nFound %d %s\n", count, strings.Pluralize("note", count)) fmt.Printf("\nFound %d %s\n", count, strings.Pluralize("note", count))
} }

@ -24,6 +24,7 @@ type Metadata struct {
Body string Body string
RawContent string RawContent string
WordCount int WordCount int
Links []Link
Created time.Time Created time.Time
Modified time.Time Modified time.Time
Checksum string Checksum string
@ -56,21 +57,21 @@ type Indexer interface {
// Indexed returns the list of indexed note file metadata. // Indexed returns the list of indexed note file metadata.
Indexed() (<-chan paths.Metadata, error) Indexed() (<-chan paths.Metadata, error)
// Add indexes a new note from its metadata. // Add indexes a new note from its metadata.
Add(metadata Metadata) error Add(metadata Metadata) (int64, error)
// Update updates the metadata of an already indexed note. // Update updates the metadata of an already indexed note.
Update(metadata Metadata) error Update(metadata Metadata) error
// Remove deletes a note from the index. // Remove deletes a note from the index.
Remove(path string) error Remove(path string) error
} }
// Index indexes the content of the notes in the given directory. // Index indexes the content of the notes in the given slip box.
func Index(dir zk.Dir, force bool, parser Parser, indexer Indexer, logger util.Logger, callback func(change paths.DiffChange)) (IndexingStats, error) { func Index(zk *zk.Zk, force bool, parser Parser, indexer Indexer, logger util.Logger, callback func(change paths.DiffChange)) (IndexingStats, error) {
wrap := errors.Wrapper("indexing failed") wrap := errors.Wrapper("indexing failed")
stats := IndexingStats{} stats := IndexingStats{}
startTime := time.Now() startTime := time.Now()
source := paths.Walk(dir.Path, dir.Config.Extension, logger) source := paths.Walk(zk.Path, zk.Config.Extension, logger)
target, err := indexer.Indexed() target, err := indexer.Indexed()
if err != nil { if err != nil {
return stats, wrap(err) return stats, wrap(err)
@ -82,15 +83,15 @@ func Index(dir zk.Dir, force bool, parser Parser, indexer Indexer, logger util.L
switch change.Kind { switch change.Kind {
case paths.DiffAdded: case paths.DiffAdded:
stats.AddedCount += 1 stats.AddedCount += 1
metadata, err := metadata(change.Path, dir.Path, parser) metadata, err := metadata(change.Path, zk, parser)
if err == nil { if err == nil {
err = indexer.Add(metadata) _, err = indexer.Add(metadata)
} }
logger.Err(err) logger.Err(err)
case paths.DiffModified: case paths.DiffModified:
stats.ModifiedCount += 1 stats.ModifiedCount += 1
metadata, err := metadata(change.Path, dir.Path, parser) metadata, err := metadata(change.Path, zk, parser)
if err == nil { if err == nil {
err = indexer.Update(metadata) err = indexer.Update(metadata)
} }
@ -111,12 +112,13 @@ func Index(dir zk.Dir, force bool, parser Parser, indexer Indexer, logger util.L
} }
// metadata retrieves note metadata for the given file. // metadata retrieves note metadata for the given file.
func metadata(path string, basePath string, parser Parser) (Metadata, error) { func metadata(path string, zk *zk.Zk, parser Parser) (Metadata, error) {
metadata := Metadata{ metadata := Metadata{
Path: path, Path: path,
Links: make([]Link, 0),
} }
absPath := filepath.Join(basePath, path) absPath := filepath.Join(zk.Path, path)
content, err := ioutil.ReadFile(absPath) content, err := ioutil.ReadFile(absPath)
if err != nil { if err != nil {
return metadata, err return metadata, err
@ -131,8 +133,21 @@ func metadata(path string, basePath string, parser Parser) (Metadata, error) {
metadata.Body = contentParts.Body.String() metadata.Body = contentParts.Body.String()
metadata.RawContent = contentStr metadata.RawContent = contentStr
metadata.WordCount = len(strings.Fields(contentStr)) metadata.WordCount = len(strings.Fields(contentStr))
metadata.Links = make([]Link, 0)
metadata.Checksum = fmt.Sprintf("%x", sha256.Sum256(content)) metadata.Checksum = fmt.Sprintf("%x", sha256.Sum256(content))
for _, link := range contentParts.Links {
if !strutil.IsURL(link.Href) {
// Make the href relative to the slip box root.
href := filepath.Join(filepath.Dir(absPath), link.Href)
link.Href, err = zk.RelPath(href)
if err != nil {
return metadata, err
}
}
metadata.Links = append(metadata.Links, link)
}
times, err := times.Stat(absPath) times, err := times.Stat(absPath)
if err != nil { if err != nil {
return metadata, err return metadata, err

@ -16,9 +16,10 @@ type Content struct {
} }
type Link struct { type Link struct {
Title string Title string
Target string Href string
Rels []string External bool
Rels []string
} }
type Parser interface { type Parser interface {

@ -63,7 +63,9 @@ func fatalIfError(err error) {
// indexZk will index any slip box in the working directory. // indexZk will index any slip box in the working directory.
func indexZk(container *cmd.Container) { func indexZk(container *cmd.Container) {
(&cmd.Index{Quiet: true}).Run(container) if len(os.Args) > 1 && os.Args[1] != "index" {
(&cmd.Index{Quiet: true}).Run(container)
}
} }
// runAlias will execute a user alias if the command is one of them. // runAlias will execute a user alias if the command is one of them.

@ -2,6 +2,7 @@ package strings
import ( import (
"bufio" "bufio"
"net/url"
"strings" "strings"
) )
@ -45,3 +46,18 @@ func SplitLines(s string) []string {
func JoinLines(s string) string { func JoinLines(s string) string {
return strings.Join(SplitLines(s), " ") return strings.Join(SplitLines(s), " ")
} }
// IsURL returns whether the given string is a valid URL.
func IsURL(s string) bool {
_, err := url.ParseRequestURI(s)
if err != nil {
return false
}
u, err := url.Parse(s)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
return true
}

@ -55,3 +55,17 @@ func TestJoinLines(t *testing.T) {
test("One line\nTwo lines\n\nThree lines", "One line Two lines Three lines") test("One line\nTwo lines\n\nThree lines", "One line Two lines Three lines")
test("One line\nTwo lines\n Three lines", "One line Two lines Three lines") test("One line\nTwo lines\n Three lines", "One line Two lines Three lines")
} }
func TestIsURL(t *testing.T) {
test := func(text string, expected bool) {
assert.Equal(t, IsURL(text), expected)
}
test("", false)
test("example.com/", false)
test("path", false)
test("http://example.com", true)
test("https://example.com/dir", true)
test("http://example.com/dir", true)
test("ftp://example.com/", true)
}

Loading…
Cancel
Save