mirror of https://github.com/mickael-menu/zk
Add a command to produce a graph of the indexed notes (#106)
parent
3b05a0061d
commit
16e1904096
@ -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
|
||||||
|
}
|
@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ac4ece96d790615233beec6e47eb094200eba7178d65f4803a8df6232b9719c4
|
oid sha256:4ed584a5c1177888066b7b63eb3b5a256c5a9a404669134f722144a30314959b
|
||||||
size 86016
|
size 86016
|
||||||
|
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue