Add a command to produce a graph of the indexed notes (#106)

pull/110/head
Mickaël Menu 3 years ago committed by GitHub
parent 3b05a0061d
commit 16e1904096
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
* New `zk graph --format json` command which produces a JSON graph of the notes matching the given criteria.
* New template variables `filename` and `filename-stem` when formatting notes (e.g. with `zk list --format`) and for the [`fzf-line`](docs/tool-fzf.md) config key.
* Customize how LSP completion items appear in your editor when auto-completing links with the [`[lsp.completion]` configuration section](docs/config-lsp.md).
```toml

@ -14,9 +14,9 @@
* [Creating notes from templates](docs/note-creation.md)
* [Advanced search and filtering capabilities](docs/note-filtering.md) including [tags](docs/tags.md), links and mentions
* [Integration with your favorite editors](docs/editors-integration.md):
* [`zk.nvim`](https://github.com/megalithic/zk.nvim) for Neovim 0.5+, maintained by [Seth Messer](https://github.com/megalithic)
* [`zk-vscode`](https://github.com/mickael-menu/zk-vscode) for Visual Studio Code
* [Any LSP-compatible editor](docs/editors-integration.md)
* [`zk-vscode`](https://github.com/mickael-menu/zk-vscode) for Visual Studio Code
* (*unmaintained*) [`zk.nvim`](https://github.com/megalithic/zk.nvim) for Neovim 0.5+ by [Seth Messer](https://github.com/megalithic)
* [Interactive browser](docs/tool-fzf.md), powered by `fzf`
* [Git-style command aliases](docs/config-alias.md) and [named filters](docs/config-filter.md)
* [Made with automation in mind](docs/automation.md)

@ -196,6 +196,17 @@ func (db *DB) migrate() error {
},
NeedsReindexing: true,
},
{ // 6
SQL: []string{
// View of links with the source and target notes metadata, for simpler queries.
`CREATE VIEW resolved_links AS
SELECT l.*, s.path AS source_path, s.title AS source_title, t.path AS target_path, t.title AS target_title
FROM links l
LEFT JOIN notes s ON l.source_id = s.id
LEFT JOIN notes t ON l.target_id = t.id`,
},
},
}
needsReindexing := false

@ -27,7 +27,7 @@ func TestMigrateFrom0(t *testing.T) {
var version int
err := tx.QueryRow("PRAGMA user_version").Scan(&version)
assert.Nil(t, err)
assert.Equal(t, version, 5)
assert.Equal(t, version, 6)
_, err = tx.Exec(`
INSERT INTO notes (path, sortable_path, title, body, word_count, checksum)

@ -0,0 +1,156 @@
package sqlite
import (
"database/sql"
"fmt"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
)
// LinkDAO persists links in the SQLite database.
type LinkDAO struct {
tx Transaction
logger util.Logger
// Prepared SQL statements
addLinkStmt *LazyStmt
setLinksTargetStmt *LazyStmt
removeLinksStmt *LazyStmt
}
// NewLinkDAO creates a new instance of a DAO working on the given database
// transaction.
func NewLinkDAO(tx Transaction, logger util.Logger) *LinkDAO {
return &LinkDAO{
tx: tx,
logger: logger,
// Add a new link.
addLinkStmt: tx.PrepareLazy(`
INSERT INTO links (source_id, target_id, title, href, type, external, rels, snippet, snippet_start, snippet_end)
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 = ?
`),
}
}
// Add inserts all the outbound links of the given note.
func (d *LinkDAO) Add(links []core.ResolvedLink) error {
for _, link := range links {
sourceID := noteIDToSQL(link.SourceID)
targetID := noteIDToSQL(link.TargetID)
_, err := d.addLinkStmt.Exec(sourceID, targetID, link.Title, link.Href, link.Type, link.IsExternal, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd)
if err != nil {
return err
}
}
return nil
}
// RemoveAll removes all the outbound links of the given note.
func (d *LinkDAO) RemoveAll(id core.NoteID) error {
_, err := d.removeLinksStmt.Exec(noteIDToSQL(id))
return err
}
// SetTargetID updates the missing target_id for links matching the given href.
// FIXME: Probably doesn't work for all type of href (partial, wikilinks, etc.)
func (d *LinkDAO) SetTargetID(href string, id core.NoteID) error {
_, err := d.setLinksTargetStmt.Exec(int64(id), href)
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 []core.LinkRelation) string {
if len(rels) == 0 {
return ""
}
delimiter := "\x01"
res := delimiter
for _, rel := range rels {
res += string(rel) + delimiter
}
return res
}
func (d *LinkDAO) FindBetweenNotes(ids []core.NoteID) ([]core.ResolvedLink, error) {
links := make([]core.ResolvedLink, 0)
idsString := joinNoteIDs(ids, ",")
rows, err := d.tx.Query(fmt.Sprintf(`
SELECT id, source_id, source_path, target_id, target_path, title, href, type, external, rels, snippet, snippet_start, snippet_end
FROM resolved_links
WHERE source_id IN (%s) AND target_id IN (%s)
`, idsString, idsString))
if err != nil {
return links, err
}
defer rows.Close()
for rows.Next() {
link, err := d.scanLink(rows)
if err != nil {
d.logger.Err(err)
continue
}
if link != nil {
links = append(links, *link)
}
}
return links, nil
}
func (d *LinkDAO) scanLink(row RowScanner) (*core.ResolvedLink, error) {
var (
id, sourceID, targetID, snippetStart, snippetEnd int
sourcePath, targetPath, title, href, linkType, snippet string
external bool
rels sql.NullString
)
err := row.Scan(
&id, &sourceID, &sourcePath, &targetID, &targetPath, &title, &href,
&linkType, &external, &rels, &snippet, &snippetStart, &snippetEnd,
)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
return nil, err
default:
return &core.ResolvedLink{
SourceID: core.NoteID(sourceID),
SourcePath: sourcePath,
TargetID: core.NoteID(targetID),
TargetPath: targetPath,
Link: core.Link{
Title: title,
Href: href,
Type: core.LinkType(linkType),
IsExternal: external,
Rels: core.LinkRels(parseListFromNullString(rels)...),
Snippet: snippet,
SnippetStart: snippetStart,
SnippetEnd: snippetEnd,
},
}, nil
}
}

@ -0,0 +1,53 @@
package sqlite
import (
"fmt"
"testing"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func testLinkDAO(t *testing.T, callback func(tx Transaction, dao *LinkDAO)) {
testTransaction(t, func(tx Transaction) {
callback(tx, NewLinkDAO(tx, &util.NullLogger))
})
}
type linkRow struct {
SourceId core.NoteID
TargetId *core.NoteID
Href, Type, Title, Rels, Snippet string
SnippetStart, SnippetEnd int
IsExternal bool
}
func queryLinkRows(t *testing.T, q RowQuerier, where string) []linkRow {
links := make([]linkRow, 0)
rows, err := q.Query(fmt.Sprintf(`
SELECT source_id, target_id, title, href, type, external, rels, snippet, snippet_start, snippet_end
FROM links
WHERE %v
ORDER BY id
`, where))
assert.Nil(t, err)
for rows.Next() {
var row linkRow
var sourceId int64
var targetId *int64
err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.Type, &row.IsExternal, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd)
assert.Nil(t, err)
row.SourceId = core.NoteID(sourceId)
if targetId != nil {
row.TargetId = idPointer(*targetId)
}
links = append(links, row)
}
rows.Close()
assert.Nil(t, rows.Err())
return links
}

@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
@ -25,15 +24,12 @@ type NoteDAO struct {
logger util.Logger
// Prepared SQL statements
indexedStmt *LazyStmt
addStmt *LazyStmt
updateStmt *LazyStmt
removeStmt *LazyStmt
findIdByPathStmt *LazyStmt
findByIdStmt *LazyStmt
addLinkStmt *LazyStmt
setLinksTargetStmt *LazyStmt
removeLinksStmt *LazyStmt
indexedStmt *LazyStmt
addStmt *LazyStmt
updateStmt *LazyStmt
removeStmt *LazyStmt
findIdByPathStmt *LazyStmt
findByIdStmt *LazyStmt
}
// NewNoteDAO creates a new instance of a DAO working on the given database
@ -80,26 +76,6 @@ func NewNoteDAO(tx Transaction, logger util.Logger) *NoteDAO {
FROM notes_with_metadata
WHERE id = ?
`),
// Add a new link.
addLinkStmt: tx.PrepareLazy(`
INSERT INTO links (source_id, target_id, title, href, type, external, rels, snippet, snippet_start, snippet_end)
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 = ?
`),
}
}
@ -165,14 +141,12 @@ func (d *NoteDAO) Add(note core.Note) (core.NoteID, error) {
return 0, err
}
id := core.NoteID(lastId)
err = d.addLinks(id, note)
return id, err
return core.NoteID(lastId), err
}
// Update modifies an existing note.
func (d *NoteDAO) Update(note core.Note) (core.NoteID, error) {
id, err := d.findIdByPath(note.Path)
id, err := d.FindIdByPath(note.Path)
if err != nil {
return 0, err
}
@ -185,16 +159,6 @@ func (d *NoteDAO) Update(note core.Note) (core.NoteID, error) {
note.Title, note.Lead, note.Body, note.RawContent, note.WordCount,
metadata, note.Checksum, note.Modified, note.Path,
)
if err != nil {
return id, err
}
_, err = d.removeLinksStmt.Exec(d.idToSql(id))
if err != nil {
return id, err
}
err = d.addLinks(id, note)
return id, err
}
@ -209,43 +173,9 @@ func (d *NoteDAO) metadataToJSON(note core.Note) string {
return string(json)
}
// addLinks inserts all the outbound links of the given note.
func (d *NoteDAO) addLinks(id core.NoteID, note core.Note) error {
for _, link := range note.Links {
allowPartialMatch := (link.Type == core.LinkTypeWikiLink)
targetId, err := d.findIdByHref(link.Href, allowPartialMatch)
if err != nil {
return err
}
_, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.Type, link.IsExternal, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd)
if err != nil {
return err
}
}
_, err := d.setLinksTargetStmt.Exec(int64(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 []core.LinkRelation) string {
if len(rels) == 0 {
return ""
}
delimiter := "\x01"
res := delimiter
for _, rel := range rels {
res += string(rel) + delimiter
}
return res
}
// Remove deletes the note with the given path from the index.
func (d *NoteDAO) Remove(path string) error {
id, err := d.findIdByPath(path)
id, err := d.FindIdByPath(path)
if err != nil {
return err
}
@ -257,7 +187,7 @@ func (d *NoteDAO) Remove(path string) error {
return err
}
func (d *NoteDAO) findIdByPath(path string) (core.NoteID, error) {
func (d *NoteDAO) FindIdByPath(path string) (core.NoteID, error) {
row, err := d.findIdByPathStmt.QueryRow(path)
if err != nil {
return core.NoteID(0), err
@ -265,10 +195,10 @@ func (d *NoteDAO) findIdByPath(path string) (core.NoteID, error) {
return idForRow(row)
}
func (d *NoteDAO) findIdsByHrefs(hrefs []string, allowPartialMatch bool) ([]core.NoteID, error) {
func (d *NoteDAO) FindIdsByHrefs(hrefs []string, allowPartialMatch bool) ([]core.NoteID, error) {
ids := make([]core.NoteID, 0)
for _, href := range hrefs {
id, err := d.findIdByHref(href, allowPartialMatch)
id, err := d.FindIdByHref(href, allowPartialMatch)
if err != nil {
return ids, err
}
@ -283,9 +213,9 @@ func (d *NoteDAO) findIdsByHrefs(hrefs []string, allowPartialMatch bool) ([]core
return ids, nil
}
func (d *NoteDAO) findIdByHref(href string, allowPartialMatch bool) (core.NoteID, error) {
func (d *NoteDAO) FindIdByHref(href string, allowPartialMatch bool) (core.NoteID, error) {
if allowPartialMatch {
id, err := d.findIdByHref(href, false)
id, err := d.FindIdByHref(href, false)
if id.IsValid() || err != nil {
return id, err
}
@ -382,6 +312,7 @@ func parseListFromNullString(str sql.NullString) []string {
if str.Valid && str.String != "" {
list = strings.Split(str.String, "\x01")
list = strutil.RemoveDuplicates(list)
list = strutil.RemoveBlank(list)
}
return list
}
@ -397,7 +328,7 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts core.NoteFindOpts) (core.NoteFind
}
// Find the IDs for the mentioned paths.
ids, err := d.findIdsByHrefs(opts.Mention, true /* allowPartialMatch */)
ids, err := d.FindIdsByHrefs(opts.Mention, true /* allowPartialMatch */)
if err != nil {
return opts, err
}
@ -408,7 +339,7 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts core.NoteFindOpts) (core.NoteFind
}
// Find their titles.
titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + d.joinIds(ids, ",") + ")"
titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + joinNoteIDs(ids, ",") + ")"
rows, err := d.tx.Query(titlesQuery)
if err != nil {
return opts, err
@ -460,14 +391,14 @@ func (d *NoteDAO) findRows(opts core.NoteFindOpts, selection noteSelection) (*sq
maxDistance := 0
setupLinkFilter := func(hrefs []string, direction int, negate, recursive bool) error {
ids, err := d.findIdsByHrefs(hrefs, true /* allowPartialMatch */)
ids, err := d.FindIdsByHrefs(hrefs, true /* allowPartialMatch */)
if err != nil {
return err
}
if len(ids) == 0 {
return nil
}
idsList := "(" + d.joinIds(ids, ",") + ")"
idsList := "(" + joinNoteIDs(ids, ",") + ")"
linksSrc := "links"
@ -614,7 +545,7 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
}
if opts.MentionedBy != nil {
ids, err := d.findIdsByHrefs(opts.MentionedBy, true /* allowPartialMatch */)
ids, err := d.FindIdsByHrefs(opts.MentionedBy, true /* allowPartialMatch */)
if err != nil {
return nil, err
}
@ -625,7 +556,7 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
}
snippetCol = `snippet(nsrc.notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20)`
joinClauses = append(joinClauses, "JOIN notes_fts nsrc ON nsrc.rowid IN ("+d.joinIds(ids, ",")+") AND nsrc.notes_fts MATCH mention_query(n.title, n.metadata)")
joinClauses = append(joinClauses, "JOIN notes_fts nsrc ON nsrc.rowid IN ("+joinNoteIDs(ids, ",")+") AND nsrc.notes_fts MATCH mention_query(n.title, n.metadata)")
}
if opts.LinkedBy != nil {
@ -682,7 +613,7 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
}
if opts.ExcludeIDs != nil {
whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIDs, ",")+")")
whereExprs = append(whereExprs, "n.id NOT IN ("+joinNoteIDs(opts.ExcludeIDs, ",")+")")
}
orderTerms := []string{}
@ -876,28 +807,6 @@ func pathRegex(path string) string {
return path + "[^/]*|" + path + "/.+"
}
func (d *NoteDAO) idToSql(id core.NoteID) sql.NullInt64 {
if id.IsValid() {
return sql.NullInt64{Int64: int64(id), Valid: true}
} else {
return sql.NullInt64{}
}
}
func (d *NoteDAO) joinIds(ids []core.NoteID, delimiter string) string {
strs := make([]string, 0)
for _, i := range ids {
strs = append(strs, strconv.FormatInt(int64(i), 10))
}
return strings.Join(strs, delimiter)
}
func 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
}
// buildMentionQuery creates an FTS5 predicate to match the given note's title
// (or aliases from the metadata) in the content of another note.
//
@ -939,7 +848,3 @@ func buildMentionQuery(title, metadataJSON string) string {
return "(" + strings.Join(titles, " OR ") + ")"
}
type RowScanner interface {
Scan(dest ...interface{}) error
}

@ -135,108 +135,6 @@ func TestNoteDAOAdd(t *testing.T) {
})
}
func TestNoteDAOAddWithLinks(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
id, err := dao.Add(core.Note{
Path: "log/added.md",
Links: []core.Link{
{
Title: "Same dir",
Href: "log/2021-01-04",
Rels: core.LinkRels("rel-1", "rel-2"),
},
{
Title: "Relative",
Href: "f39c8",
Snippet: "[Relative](f39c8) link",
SnippetStart: 50,
SnippetEnd: 100,
},
{
Title: "Second is added",
Href: "f39c8#anchor",
Rels: core.LinkRels("second"),
},
{
Title: "Unknown",
Href: "unknown",
},
{
Title: "URL",
Href: "http://example.com",
IsExternal: true,
Snippet: "External [URL](http://example.com)",
},
},
})
assert.Nil(t, err)
rows := queryLinkRows(t, tx, fmt.Sprintf("source_id = %d", id))
assert.Equal(t, rows, []linkRow{
{
SourceId: id,
TargetId: idPointer(2),
Title: "Same dir",
Href: "log/2021-01-04",
Rels: "\x01rel-1\x01rel-2\x01",
},
{
SourceId: id,
TargetId: idPointer(4),
Title: "Relative",
Href: "f39c8",
Rels: "",
Snippet: "[Relative](f39c8) link",
SnippetStart: 50,
SnippetEnd: 100,
},
{
SourceId: id,
TargetId: idPointer(4),
Title: "Second is added",
Href: "f39c8#anchor",
Rels: "\x01second\x01",
},
{
SourceId: id,
TargetId: nil,
Title: "Unknown",
Href: "unknown",
Rels: "",
},
{
SourceId: id,
TargetId: nil,
Title: "URL",
Href: "http://example.com",
IsExternal: true,
Rels: "",
Snippet: "External [URL](http://example.com)",
},
})
})
}
func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
id, err := dao.Add(core.Note{
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",
Snippet: "There's a Missing target",
},
})
})
}
// Check that we can't add a duplicate note with an existing path.
func TestNoteDAOAddExistingNote(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
@ -288,73 +186,6 @@ 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: idPointer(2),
Title: "An internal link",
Href: "log/2021-01-04.md",
Snippet: "[[An internal link]]",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
IsExternal: true,
Snippet: "[[An external link]]",
},
})
_, err := dao.Update(core.Note{
Path: "log/2021-01-03.md",
Links: []core.Link{
{
Title: "A new link",
Href: "index",
Type: core.LinkTypeWikiLink,
IsExternal: false,
Rels: core.LinkRels("rel"),
Snippet: "[[A new link]]",
},
{
Title: "An external link",
Href: "https://domain.com",
Type: core.LinkTypeMarkdown,
IsExternal: true,
Snippet: "[[An external link]]",
},
},
})
assert.Nil(t, err)
links = queryLinkRows(t, tx, "source_id = 1")
assert.Equal(t, links, []linkRow{
{
SourceId: 1,
TargetId: idPointer(3),
Title: "A new link",
Href: "index",
Type: "wiki-link",
Rels: "\x01rel\x01",
Snippet: "[[A new link]]",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
Type: "markdown",
IsExternal: true,
Snippet: "[[An external link]]",
},
})
})
}
func TestNoteDAORemove(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
_, err := queryNoteRow(tx, `path = "ref/test/a.md"`)
@ -1160,43 +991,6 @@ func queryNoteRow(tx Transaction, where string) (noteRow, error) {
return row, err
}
type linkRow struct {
SourceId core.NoteID
TargetId *core.NoteID
Href, Type, Title, Rels, Snippet string
SnippetStart, SnippetEnd int
IsExternal 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, type, external, rels, snippet, snippet_start, snippet_end
FROM links
WHERE %v
ORDER BY id
`, where))
assert.Nil(t, err)
for rows.Next() {
var row linkRow
var sourceId int64
var targetId *int64
err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.Type, &row.IsExternal, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd)
assert.Nil(t, err)
row.SourceId = core.NoteID(sourceId)
if targetId != nil {
row.TargetId = idPointer(*targetId)
}
links = append(links, row)
}
rows.Close()
assert.Nil(t, rows.Err())
return links
}
func idPointer(i int64) *core.NoteID {
id := core.NoteID(i)
return &id

@ -17,6 +17,7 @@ type NoteIndex struct {
type dao struct {
notes *NoteDAO
links *LinkDAO
collections *CollectionDAO
metadata *MetadataDAO
}
@ -46,6 +47,15 @@ func (ni *NoteIndex) FindMinimal(opts core.NoteFindOpts) (notes []core.MinimalNo
return
}
// FindLinksBetweenNotes implements core.NoteIndex.
func (ni *NoteIndex) FindLinksBetweenNotes(ids []core.NoteID) (links []core.ResolvedLink, err error) {
err = ni.commit(func(dao *dao) error {
links, err = dao.links.FindBetweenNotes(ids)
return err
})
return
}
// FindCollections implements core.NoteIndex.
func (ni *NoteIndex) FindCollections(kind core.CollectionKind, sorters []core.CollectionSorter) (collections []core.Collection, err error) {
err = ni.commit(func(dao *dao) error {
@ -73,6 +83,11 @@ func (ni *NoteIndex) Add(note core.Note) (id core.NoteID, err error) {
return err
}
err = ni.addLinks(dao, id, note)
if err != nil {
return err
}
return ni.associateTags(dao.collections, id, note.Tags)
})
@ -83,17 +98,27 @@ func (ni *NoteIndex) Add(note core.Note) (id core.NoteID, err error) {
// Update implements core.NoteIndex.
func (ni *NoteIndex) Update(note core.Note) error {
err := ni.commit(func(dao *dao) error {
noteId, err := dao.notes.Update(note)
id, err := dao.notes.Update(note)
if err != nil {
return err
}
err = dao.collections.RemoveAssociations(noteId)
// Reset links
err = dao.links.RemoveAll(id)
if err != nil {
return err
}
err = ni.addLinks(dao, id, note)
if err != nil {
return err
}
return ni.associateTags(dao.collections, noteId, note.Tags)
// Reset tags
err = dao.collections.RemoveAssociations(id)
if err != nil {
return err
}
return ni.associateTags(dao.collections, id, note.Tags)
})
return errors.Wrapf(err, "%v: failed to update note index", note.Path)
@ -114,6 +139,40 @@ func (ni *NoteIndex) associateTags(collections *CollectionDAO, noteId core.NoteI
return nil
}
func (ni *NoteIndex) addLinks(dao *dao, id core.NoteID, note core.Note) error {
links, err := ni.resolveLinkNoteIDs(dao, id, note.Links)
if err != nil {
return err
}
err = dao.links.Add(links)
if err != nil {
return err
}
return dao.links.SetTargetID(note.Path, id)
}
func (ni *NoteIndex) resolveLinkNoteIDs(dao *dao, sourceID core.NoteID, links []core.Link) ([]core.ResolvedLink, error) {
resolvedLinks := []core.ResolvedLink{}
for _, link := range links {
allowPartialMatch := (link.Type == core.LinkTypeWikiLink)
targetID, err := dao.notes.FindIdByHref(link.Href, allowPartialMatch)
if err != nil {
return resolvedLinks, err
}
resolvedLinks = append(resolvedLinks, core.ResolvedLink{
Link: link,
SourceID: sourceID,
TargetID: targetID,
})
}
return resolvedLinks, nil
}
// Remove implements core.NoteIndex
func (ni *NoteIndex) Remove(path string) error {
err := ni.commit(func(dao *dao) error {
@ -162,6 +221,7 @@ func (ni *NoteIndex) commit(transaction func(dao *dao) error) error {
return ni.db.WithTransaction(func(tx Transaction) error {
dao := dao{
notes: NewNoteDAO(tx, ni.logger),
links: NewLinkDAO(tx, ni.logger),
collections: NewCollectionDAO(tx, ni.logger),
metadata: NewMetadataDAO(tx),
}

@ -1,6 +1,7 @@
package sqlite
import (
"fmt"
"testing"
"github.com/mickael-menu/zk/internal/core"
@ -10,6 +11,175 @@ import (
// FIXME: Missing tests
func TestNoteIndexAddWithLinks(t *testing.T) {
db, index := testNoteIndex(t)
id, err := index.Add(core.Note{
Path: "log/added.md",
Links: []core.Link{
{
Title: "Same dir",
Href: "log/2021-01-04",
Rels: core.LinkRels("rel-1", "rel-2"),
},
{
Title: "Relative",
Href: "f39c8",
Snippet: "[Relative](f39c8) link",
SnippetStart: 50,
SnippetEnd: 100,
},
{
Title: "Second is added",
Href: "f39c8#anchor",
Rels: core.LinkRels("second"),
},
{
Title: "Unknown",
Href: "unknown",
},
{
Title: "URL",
Href: "http://example.com",
IsExternal: true,
Snippet: "External [URL](http://example.com)",
},
},
})
assert.Nil(t, err)
rows := queryLinkRows(t, db.db, fmt.Sprintf("source_id = %d", id))
assert.Equal(t, rows, []linkRow{
{
SourceId: id,
TargetId: idPointer(2),
Title: "Same dir",
Href: "log/2021-01-04",
Rels: "\x01rel-1\x01rel-2\x01",
},
{
SourceId: id,
TargetId: idPointer(4),
Title: "Relative",
Href: "f39c8",
Rels: "",
Snippet: "[Relative](f39c8) link",
SnippetStart: 50,
SnippetEnd: 100,
},
{
SourceId: id,
TargetId: idPointer(4),
Title: "Second is added",
Href: "f39c8#anchor",
Rels: "\x01second\x01",
},
{
SourceId: id,
TargetId: nil,
Title: "Unknown",
Href: "unknown",
Rels: "",
},
{
SourceId: id,
TargetId: nil,
Title: "URL",
Href: "http://example.com",
IsExternal: true,
Rels: "",
Snippet: "External [URL](http://example.com)",
},
})
}
func TestNoteIndexAddFillsLinksMissingTargetId(t *testing.T) {
db, index := testNoteIndex(t)
id, err := index.Add(core.Note{
Path: "missing_target.md",
})
assert.Nil(t, err)
rows := queryLinkRows(t, db.db, fmt.Sprintf("target_id = %d", id))
assert.Equal(t, rows, []linkRow{
{
SourceId: 3,
TargetId: &id,
Title: "Missing target",
Href: "missing",
Snippet: "There's a Missing target",
},
})
}
func TestNoteIndexUpdateWithLinks(t *testing.T) {
db, index := testNoteIndex(t)
links := queryLinkRows(t, db.db, "source_id = 1")
assert.Equal(t, links, []linkRow{
{
SourceId: 1,
TargetId: idPointer(2),
Title: "An internal link",
Href: "log/2021-01-04.md",
Snippet: "[[An internal link]]",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
IsExternal: true,
Snippet: "[[An external link]]",
},
})
err := index.Update(core.Note{
Path: "log/2021-01-03.md",
Links: []core.Link{
{
Title: "A new link",
Href: "index",
Type: core.LinkTypeWikiLink,
IsExternal: false,
Rels: core.LinkRels("rel"),
Snippet: "[[A new link]]",
},
{
Title: "An external link",
Href: "https://domain.com",
Type: core.LinkTypeMarkdown,
IsExternal: true,
Snippet: "[[An external link]]",
},
},
})
assert.Nil(t, err)
links = queryLinkRows(t, db.db, "source_id = 1")
assert.Equal(t, links, []linkRow{
{
SourceId: 1,
TargetId: idPointer(3),
Title: "A new link",
Href: "index",
Type: "wiki-link",
Rels: "\x01rel\x01",
Snippet: "[[A new link]]",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
Type: "markdown",
IsExternal: true,
Snippet: "[[An external link]]",
},
})
}
func TestNoteIndexAddWithTags(t *testing.T) {
db, index := testNoteIndex(t)

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac4ece96d790615233beec6e47eb094200eba7178d65f4803a8df6232b9719c4
oid sha256:4ed584a5c1177888066b7b63eb3b5a256c5a9a404669134f722144a30314959b
size 86016

@ -1,6 +1,23 @@
package sqlite
import "strings"
import (
"database/sql"
"encoding/json"
"strconv"
"strings"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/errors"
)
type RowScanner interface {
Scan(dest ...interface{}) error
}
type RowQuerier interface {
Query(query string, args ...interface{}) (*sql.Rows, error)
QueryRow(query string, args ...interface{}) *sql.Row
}
// escapeLikeTerm returns the given term after escaping any LIKE-significant
// characters with the given escapeChar.
@ -12,3 +29,25 @@ func escapeLikeTerm(term string, escapeChar rune) string {
}
return escape(escape(escape(term, string(escapeChar)), "%"), "_")
}
func noteIDToSQL(id core.NoteID) sql.NullInt64 {
if id.IsValid() {
return sql.NullInt64{Int64: int64(id), Valid: true}
} else {
return sql.NullInt64{}
}
}
func joinNoteIDs(ids []core.NoteID, delimiter string) string {
strs := make([]string, 0)
for _, i := range ids {
strs = append(strs, strconv.FormatInt(int64(i), 10))
}
return strings.Join(strs, delimiter)
}
func 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
}

@ -0,0 +1,97 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"github.com/mickael-menu/zk/internal/adapter/fzf"
"github.com/mickael-menu/zk/internal/cli"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/strings"
)
// Graph produces a directed graph of the notes matching a set of criteria.
type Graph struct {
Format string `group:format short:f help:"Format of the graph among: json." enum:"json" required`
Quiet bool `group:format short:q help:"Do not print the total number of notes found."`
cli.Filtering
}
func (cmd *Graph) Run(container *cli.Container) error {
notebook, err := container.CurrentNotebook()
if err != nil {
return err
}
format, err := notebook.NewNoteFormatter("{{json .}}")
if err != nil {
return err
}
findOpts, err := cmd.Filtering.NewNoteFindOpts(notebook)
if err != nil {
return errors.Wrapf(err, "incorrect criteria")
}
notes, err := notebook.FindNotes(findOpts)
if err != nil {
return err
}
noteIDs := []core.NoteID{}
for _, note := range notes {
noteIDs = append(noteIDs, note.ID)
}
links, err := notebook.FindLinksBetweenNotes(noteIDs)
if err != nil {
return err
}
filter := container.NewNoteFilter(fzf.NoteFilterOpts{
Interactive: cmd.Interactive,
AlwaysFilter: false,
NotebookDir: notebook.Path,
})
notes, err = filter.Apply(notes)
if err != nil {
if err == fzf.ErrCancelled {
return nil
}
return err
}
fmt.Print("{\n \"notes\": [\n")
for i, note := range notes {
if i > 0 {
fmt.Print(",\n")
}
ft, err := format(note)
if err != nil {
return err
}
fmt.Printf(" %s", ft)
}
fmt.Print("\n ],\n \"links\": [\n")
for i, link := range links {
if i > 0 {
fmt.Print(",\n")
}
ft, err := json.Marshal(link)
if err != nil {
return err
}
fmt.Printf(" %s", string(ft))
}
fmt.Print("\n ]\n}\n")
if err == nil && !cmd.Quiet {
count := len(notes)
fmt.Fprintf(os.Stderr, "\n\nFound %d %s\n", count, strings.Pluralize("note", count))
}
return err
}

@ -3,21 +3,30 @@ package core
// Link represents a link in a note to another note or an external resource.
type Link struct {
// Label of the link.
Title string
Title string `json:"title"`
// Destination URI of the link.
Href string
Href string `json:"href"`
// Type of link, e.g. wiki link.
Type LinkType
Type LinkType `json:"type"`
// Indicates whether the target is a remote (e.g. HTTP) resource.
IsExternal bool
IsExternal bool `json:"isExternal"`
// Relationships between the note and the linked target.
Rels []LinkRelation
Rels []LinkRelation `json:"rels"`
// Excerpt of the paragraph containing the note.
Snippet string
Snippet string `json:"snippet"`
// Start byte offset of the snippet in the note content.
SnippetStart int
SnippetStart int `json:"snippetStart"`
// End byte offset of the snippet in the note content.
SnippetEnd int
SnippetEnd int `json:"snippetEnd"`
}
// ResolvedLink represents a link between two indexed notes.
type ResolvedLink struct {
Link
SourceID NoteID `json:"sourceId"`
SourcePath string `json:"sourcePath"`
TargetID NoteID `json:"targetId"`
TargetPath string `json:"targetPath"`
}
// LinkType represents the kind of link, e.g. wiki link.

@ -19,6 +19,9 @@ type NoteIndex interface {
// given filtering and sorting criteria.
FindMinimal(opts NoteFindOpts) ([]MinimalNote, error)
// FindLinksBetweenNotes retrieves the links between the given notes.
FindLinksBetweenNotes(ids []NoteID) ([]ResolvedLink, error)
// FindCollections retrieves all the collections of the given kind.
FindCollections(kind CollectionKind, sorters []CollectionSorter) ([]Collection, error)

@ -504,6 +504,9 @@ type noteIndexAddMock struct {
func (m *noteIndexAddMock) Find(opts NoteFindOpts) ([]ContextualNote, error) { return nil, nil }
func (m *noteIndexAddMock) FindMinimal(opts NoteFindOpts) ([]MinimalNote, error) { return nil, nil }
func (m *noteIndexAddMock) FindLinksBetweenNotes(ids []NoteID) ([]ResolvedLink, error) {
return nil, nil
}
func (m *noteIndexAddMock) FindCollections(kind CollectionKind, sorters []CollectionSorter) ([]Collection, error) {
return nil, nil
}

@ -236,6 +236,11 @@ func (n *Notebook) FindMatching(terms string) (*MinimalNote, error) {
})
}
// FindLinksBetweenNotes retrieves the links between the given notes.
func (n *Notebook) FindLinksBetweenNotes(ids []NoteID) ([]ResolvedLink, error) {
return n.index.FindLinksBetweenNotes(ids)
}
// FindCollections retrieves all the collections of the given kind.
func (n *Notebook) FindCollections(kind CollectionKind, sorters []CollectionSorter) ([]Collection, error) {
return n.index.FindCollections(kind, sorters)

@ -92,6 +92,22 @@ func RemoveDuplicates(strings []string) []string {
return res
}
// RemoveBlank keeps only non-empty strings in the source.
func RemoveBlank(strs []string) []string {
if strs == nil {
return nil
}
res := make([]string, 0)
for _, val := range strs {
if strings.TrimSpace(val) != "" {
res = append(res, val)
}
}
return res
}
// InList returns whether the string is part of the given list of strings.
func InList(strings []string, s string) bool {
for _, c := range strings {

@ -94,6 +94,19 @@ func TestRemoveDuplicates(t *testing.T) {
test([]string{"One", "Two", "OneTwo"}, []string{"One", "Two", "OneTwo"})
}
func TestRemoveBlank(t *testing.T) {
test := func(items []string, expected []string) {
assert.Equal(t, RemoveBlank(items), expected)
}
test([]string{}, []string{})
test([]string{"One"}, []string{"One"})
test([]string{"One", "Two"}, []string{"One", "Two"})
test([]string{"One", "Two", ""}, []string{"One", "Two"})
test([]string{"Two", "One", " "}, []string{"Two", "One"})
test([]string{"One", "Two", " "}, []string{"One", "Two"})
}
func TestInList(t *testing.T) {
test := func(items []string, s string, expected bool) {
assert.Equal(t, InList(items, s), expected)

@ -22,10 +22,11 @@ var root struct {
Init cmd.Init `cmd group:"zk" help:"Create a new notebook in the given directory."`
Index cmd.Index `cmd group:"zk" help:"Index the notes to be searchable."`
New cmd.New `cmd group:"notes" help:"Create a new note in the given notebook directory."`
List cmd.List `cmd group:"notes" help:"List notes matching the given criteria."`
Edit cmd.Edit `cmd group:"notes" help:"Edit notes matching the given criteria."`
Tag cmd.Tag `cmd group:"notes" help:"Manage the note tags."`
New cmd.New `cmd group:"notes" help:"Create a new note in the given notebook directory."`
List cmd.List `cmd group:"notes" help:"List notes matching the given criteria."`
Graph cmd.Graph `cmd group:"notes" help:"Produce a graph of the notes matching the given criteria."`
Edit cmd.Edit `cmd group:"notes" help:"Edit notes matching the given criteria."`
Tag cmd.Tag `cmd group:"notes" help:"Manage the note tags."`
NotebookDir string `type:path placeholder:PATH help:"Turn off notebook auto-discovery and set manually the notebook where commands are run."`
WorkingDir string `short:W type:path placeholder:PATH help:"Run as if zk was started in <PATH> instead of the current working directory."`
@ -104,9 +105,10 @@ func options(container *cli.Container) []kong.Option {
kong.Name("zk"),
kong.UsageOnError(),
kong.HelpOptions{
Compact: true,
FlagsLast: true,
WrapUpperBound: 100,
Compact: true,
FlagsLast: true,
WrapUpperBound: 100,
NoExpandSubcommands: true,
},
kong.Vars{
"version": "zk " + strings.TrimPrefix(Version, "v"),

Loading…
Cancel
Save