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
|
||||
oid sha256:ac4ece96d790615233beec6e47eb094200eba7178d65f4803a8df6232b9719c4
|
||||
oid sha256:4ed584a5c1177888066b7b63eb3b5a256c5a9a404669134f722144a30314959b
|
||||
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