Skip to content

Commit

Permalink
Add a command to produce a graph of the indexed notes (#106)
Browse files Browse the repository at this point in the history
  • Loading branch information
mickael-menu authored Nov 14, 2021
1 parent 3b05a00 commit 16e1904
Show file tree
Hide file tree
Showing 20 changed files with 683 additions and 346 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions internal/adapter/sqlite/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/sqlite/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
156 changes: 156 additions & 0 deletions internal/adapter/sqlite/link_dao.go
Original file line number Diff line number Diff line change
@@ -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
}
}
53 changes: 53 additions & 0 deletions internal/adapter/sqlite/link_dao_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 16e1904

Please sign in to comment.