Architecture (#27)

* Move everything under the internal package.
* Better separation between core and adapter packages, for easier unit testing.
* Simplify data models.
* Support multiple opened notebooks during runtime (useful for the LSP server).
* Proper surface API which might be exposed later as a public Go package.
pull/29/head
Mickaël Menu 3 years ago committed by GitHub
parent dd561be1a7
commit 50855154e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,219 +0,0 @@
package adapter
import (
"io"
"os"
"path/filepath"
"time"
"github.com/mickael-menu/zk/adapter/fzf"
"github.com/mickael-menu/zk/adapter/handlebars"
"github.com/mickael-menu/zk/adapter/markdown"
"github.com/mickael-menu/zk/adapter/sqlite"
"github.com/mickael-menu/zk/adapter/term"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/date"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/pager"
"github.com/mickael-menu/zk/util/paths"
"github.com/schollz/progressbar/v3"
)
type Container struct {
Version string
Config zk.Config
Date date.Provider
Logger util.Logger
Terminal *term.Terminal
WorkingDir string
templateLoader *handlebars.Loader
zk *zk.Zk
zkErr error
}
func NewContainer(version string) (*Container, error) {
wrap := errors.Wrapper("initialization")
config := zk.NewDefaultConfig()
// Load global user config
configPath, err := locateGlobalConfig()
if err != nil {
return nil, wrap(err)
}
if configPath != "" {
config, err = zk.OpenConfig(configPath, config)
if err != nil {
return nil, wrap(err)
}
}
date := date.NewFrozenNow()
return &Container{
Version: version,
Config: config,
// zk is short-lived, so we freeze the current date to use the same
// date for any template rendering during the execution.
Date: &date,
Logger: util.NewStdLogger("zk: ", 0),
Terminal: term.New(),
}, nil
}
// locateGlobalConfig looks for the global zk config file following the
// XDG Base Directory specification
// https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
func locateGlobalConfig() (string, error) {
configHome, ok := os.LookupEnv("XDG_CONFIG_HOME")
if !ok {
home, ok := os.LookupEnv("HOME")
if !ok {
home = "~/"
}
configHome = filepath.Join(home, ".config")
}
configPath := filepath.Join(configHome, "zk/config.toml")
exists, err := paths.Exists(configPath)
switch {
case err != nil:
return "", err
case exists:
return configPath, nil
default:
return "", nil
}
}
// OpenNotebook resolves and loads the first notebook found in the given
// searchPaths.
func (c *Container) OpenNotebook(searchPaths []string) {
if len(searchPaths) == 0 {
panic("no notebook search paths provided")
}
for _, path := range searchPaths {
c.zk, c.zkErr = zk.Open(path, c.Config)
if c.zkErr == nil {
c.WorkingDir = path
c.Config = c.zk.Config
os.Setenv("ZK_NOTEBOOK_DIR", c.zk.Path)
return
}
}
}
func (c *Container) Zk() (*zk.Zk, error) {
return c.zk, c.zkErr
}
func (c *Container) TemplateLoader(lang string) *handlebars.Loader {
if c.templateLoader == nil {
handlebars.Init(lang, c.Terminal.SupportsUTF8(), c.Logger, c.Terminal)
c.templateLoader = handlebars.NewLoader()
}
return c.templateLoader
}
func (c *Container) Parser() *markdown.Parser {
return markdown.NewParser(markdown.ParserOpts{
HashtagEnabled: c.Config.Format.Markdown.Hashtags,
MultiWordTagEnabled: c.Config.Format.Markdown.MultiwordTags,
ColontagEnabled: c.Config.Format.Markdown.ColonTags,
})
}
func (c *Container) NoteFinder(tx sqlite.Transaction, opts fzf.NoteFinderOpts) *fzf.NoteFinder {
notes := sqlite.NewNoteDAO(tx, c.Logger)
return fzf.NewNoteFinder(opts, notes, c.Terminal)
}
func (c *Container) NoteIndexer(tx sqlite.Transaction) *sqlite.NoteIndexer {
notes := sqlite.NewNoteDAO(tx, c.Logger)
collections := sqlite.NewCollectionDAO(tx, c.Logger)
return sqlite.NewNoteIndexer(notes, collections, c.Logger)
}
// Database returns the DB instance for the given notebook, after executing any
// pending migration and indexing the notes if needed.
func (c *Container) Database(forceIndexing bool) (*sqlite.DB, note.IndexingStats, error) {
var stats note.IndexingStats
if c.zkErr != nil {
return nil, stats, c.zkErr
}
db, err := sqlite.Open(c.zk.DBPath())
if err != nil {
return nil, stats, err
}
needsReindexing, err := db.Migrate()
if err != nil {
return nil, stats, errors.Wrap(err, "failed to migrate the database")
}
stats, err = c.index(db, forceIndexing || needsReindexing)
if err != nil {
return nil, stats, err
}
return db, stats, err
}
func (c *Container) index(db *sqlite.DB, force bool) (note.IndexingStats, error) {
var bar = progressbar.NewOptions(-1,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionThrottle(100*time.Millisecond),
progressbar.OptionSpinnerType(14),
)
var err error
var stats note.IndexingStats
if c.zkErr != nil {
return stats, c.zkErr
}
err = db.WithTransaction(func(tx sqlite.Transaction) error {
stats, err = note.Index(
c.zk,
force,
c.Parser(),
c.NoteIndexer(tx),
c.Logger,
func(change paths.DiffChange) {
bar.Add(1)
bar.Describe(change.String())
},
)
return err
})
bar.Clear()
return stats, err
}
// Paginate creates an auto-closing io.Writer which will be automatically
// paginated if noPager is false, using the user's pager.
//
// You can write to the pager only in the run callback.
func (c *Container) Paginate(noPager bool, run func(out io.Writer) error) error {
pager, err := c.pager(noPager || c.Config.Tool.Pager.IsEmpty())
if err != nil {
return err
}
err = run(pager)
pager.Close()
return err
}
func (c *Container) pager(noPager bool) (*pager.Pager, error) {
if noPager || !c.Terminal.IsInteractive() {
return pager.PassthroughPager, nil
} else {
return pager.New(c.Config.Tool.Pager, c.Logger)
}
}

@ -1,131 +0,0 @@
package fzf
import (
"fmt"
"os"
"path/filepath"
"github.com/mickael-menu/zk/adapter/term"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/opt"
stringsutil "github.com/mickael-menu/zk/util/strings"
)
// NoteFinder wraps a note.Finder and filters its result interactively using fzf.
type NoteFinder struct {
opts NoteFinderOpts
finder note.Finder
terminal *term.Terminal
}
// NoteFinderOpts holds the configuration for the fzf notes finder.
//
// The absolute path to the notebook (BasePath) and the working directory
// (CurrentPath) are used to make the path of each note relative to the working
// directory.
type NoteFinderOpts struct {
// Indicates whether fzf is opened for every query, even if empty.
AlwaysFilter bool
// Preview command to run when selecting a note.
PreviewCmd opt.String
// When non nil, a "create new note from query" binding will be added to
// fzf to create a note in this directory.
NewNoteDir *zk.Dir
// Absolute path to the notebook.
BasePath string
// Path to the working directory.
CurrentPath string
}
func NewNoteFinder(opts NoteFinderOpts, finder note.Finder, terminal *term.Terminal) *NoteFinder {
return &NoteFinder{
opts: opts,
finder: finder,
terminal: terminal,
}
}
func (f *NoteFinder) Find(opts note.FinderOpts) ([]note.Match, error) {
selectedMatches := make([]note.Match, 0)
matches, err := f.finder.Find(opts)
relPaths := []string{}
if !opts.Interactive || !f.terminal.IsInteractive() || err != nil || (!f.opts.AlwaysFilter && len(matches) == 0) {
return matches, err
}
for _, match := range matches {
absPath := filepath.Join(f.opts.BasePath, match.Path)
relPath, err := filepath.Rel(f.opts.CurrentPath, absPath)
if err != nil {
return selectedMatches, err
}
relPaths = append(relPaths, relPath)
}
zkBin, err := os.Executable()
if err != nil {
return selectedMatches, err
}
bindings := []Binding{}
if dir := f.opts.NewNoteDir; dir != nil {
suffix := ""
if dir.Name != "" {
suffix = " in " + dir.Name + "/"
}
bindings = append(bindings, Binding{
Keys: "Ctrl-N",
Description: "create a note with the query as title" + suffix,
Action: fmt.Sprintf("abort+execute(%s new %s --title {q} < /dev/tty > /dev/tty)", zkBin, dir.Path),
})
}
previewCmd := f.opts.PreviewCmd.OrString("cat {-1}").Unwrap()
if previewCmd != "" {
// The note paths will be relative to the current path, so we need to
// move there otherwise the preview command will fail.
previewCmd = `cd "` + f.opts.CurrentPath + `" && ` + previewCmd
}
fzf, err := New(Opts{
PreviewCmd: opt.NewNotEmptyString(previewCmd),
Padding: 2,
Bindings: bindings,
})
if err != nil {
return selectedMatches, err
}
for i, match := range matches {
title := match.Title
if title == "" {
title = relPaths[i]
}
fzf.Add([]string{
f.terminal.MustStyle(title, style.RuleYellow),
f.terminal.MustStyle(stringsutil.JoinLines(match.Body), style.RuleUnderstate),
f.terminal.MustStyle(relPaths[i], style.RuleUnderstate),
})
}
selection, err := fzf.Selection()
if err != nil {
return selectedMatches, err
}
for _, s := range selection {
path := s[len(s)-1]
for i, m := range matches {
if relPaths[i] == path {
selectedMatches = append(selectedMatches, m)
}
}
}
return selectedMatches, nil
}

@ -1,97 +0,0 @@
package handlebars
import (
"html"
"path/filepath"
"github.com/aymerick/raymond"
"github.com/mickael-menu/zk/adapter/handlebars/helpers"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/core/templ"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/errors"
)
func Init(lang string, supportsUTF8 bool, logger util.Logger, styler style.Styler) {
helpers.RegisterConcat()
helpers.RegisterDate(logger)
helpers.RegisterJoin()
helpers.RegisterList(supportsUTF8)
helpers.RegisterPrepend(logger)
helpers.RegisterShell(logger)
helpers.RegisterSlug(lang, logger)
helpers.RegisterStyle(styler, logger)
}
// Template renders a parsed handlebars template.
type Template struct {
template *raymond.Template
}
// Render renders the template with the given context.
func (t *Template) Render(context interface{}) (string, error) {
res, err := t.template.Exec(context)
if err != nil {
return "", errors.Wrap(err, "render template failed")
}
return html.UnescapeString(res), nil
}
// Loader loads and holds parsed handlebars templates.
type Loader struct {
strings map[string]*Template
files map[string]*Template
}
// NewLoader creates a new instance of Loader.
func NewLoader() *Loader {
return &Loader{
strings: make(map[string]*Template),
files: make(map[string]*Template),
}
}
// Load retrieves or parses a handlebars string template.
func (l *Loader) Load(content string) (templ.Renderer, error) {
wrap := errors.Wrapperf("load template failed")
// Already loaded?
template, ok := l.strings[content]
if ok {
return template, nil
}
// Load new template.
vendorTempl, err := raymond.Parse(content)
if err != nil {
return nil, wrap(err)
}
template = &Template{vendorTempl}
l.strings[content] = template
return template, nil
}
// LoadFile retrieves or parses a handlebars file template.
func (l *Loader) LoadFile(path string) (templ.Renderer, error) {
wrap := errors.Wrapper("load template file failed")
path, err := filepath.Abs(path)
if err != nil {
return nil, wrap(err)
}
// Already loaded?
template, ok := l.files[path]
if ok {
return template, nil
}
// Load new template.
vendorTempl, err := raymond.ParseFile(path)
if err != nil {
return nil, wrap(err)
}
template = &Template{vendorTempl}
l.files[path] = template
return template, nil
}

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1395602c94007495dec225bc4f1062aa7fe9a6188ba28c6419fe1e132f41778a
size 36864

@ -1,91 +0,0 @@
package sqlite
import (
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/paths"
)
// NoteIndexer persists note indexing results in the SQLite database.
// It implements the core port note.Indexer and acts as a facade to the DAOs.
type NoteIndexer struct {
tx Transaction
notes *NoteDAO
collections *CollectionDAO
logger util.Logger
}
func NewNoteIndexer(notes *NoteDAO, collections *CollectionDAO, logger util.Logger) *NoteIndexer {
return &NoteIndexer{
notes: notes,
collections: collections,
logger: logger,
}
}
// Indexed returns the list of indexed note file metadata.
func (i *NoteIndexer) Indexed() (<-chan paths.Metadata, error) {
c, err := i.notes.Indexed()
return c, errors.Wrap(err, "failed to get indexed notes")
}
// Add indexes a new note from its metadata.
func (i *NoteIndexer) Add(metadata note.Metadata) (core.NoteId, error) {
wrap := errors.Wrapperf("%v: failed to index the note", metadata.Path)
noteId, err := i.notes.Add(metadata)
if err != nil {
return 0, wrap(err)
}
err = i.associateTags(noteId, metadata.Tags)
if err != nil {
return 0, wrap(err)
}
return noteId, nil
}
// Update updates the metadata of an already indexed note.
func (i *NoteIndexer) Update(metadata note.Metadata) error {
wrap := errors.Wrapperf("%v: failed to update note index", metadata.Path)
noteId, err := i.notes.Update(metadata)
if err != nil {
return wrap(err)
}
err = i.collections.RemoveAssociations(noteId)
if err != nil {
return wrap(err)
}
err = i.associateTags(noteId, metadata.Tags)
if err != nil {
return wrap(err)
}
return err
}
func (i *NoteIndexer) associateTags(noteId core.NoteId, tags []string) error {
for _, tag := range tags {
tagId, err := i.collections.FindOrCreate(note.CollectionKindTag, tag)
if err != nil {
return err
}
_, err = i.collections.Associate(noteId, tagId)
if err != nil {
return err
}
}
return nil
}
// Remove deletes a note from the index.
func (i *NoteIndexer) Remove(path string) error {
err := i.notes.Remove(path)
return errors.Wrapf(err, "%v: failed to remove note index", path)
}

@ -1,64 +0,0 @@
package sqlite
import (
"testing"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/test/assert"
)
func TestNoteIndexerAddWithTags(t *testing.T) {
testNoteIndexer(t, func(tx Transaction, indexer *NoteIndexer) {
assertSQL := func(after bool) {
assertTagExistsOrNot(t, tx, true, "fiction")
assertTagExistsOrNot(t, tx, after, "new-tag")
}
assertSQL(false)
id, err := indexer.Add(note.Metadata{
Path: "log/added.md",
Tags: []string{"new-tag", "fiction"},
})
assert.Nil(t, err)
assertSQL(true)
assertTaggedOrNot(t, tx, true, id, "new-tag")
assertTaggedOrNot(t, tx, true, id, "fiction")
})
}
func TestNoteIndexerUpdateWithTags(t *testing.T) {
testNoteIndexer(t, func(tx Transaction, indexer *NoteIndexer) {
id := core.NoteId(1)
assertSQL := func(after bool) {
assertTaggedOrNot(t, tx, true, id, "fiction")
assertTaggedOrNot(t, tx, after, id, "new-tag")
assertTaggedOrNot(t, tx, after, id, "fantasy")
}
assertSQL(false)
err := indexer.Update(note.Metadata{
Path: "log/2021-01-03.md",
Tags: []string{"new-tag", "fiction", "fantasy"},
})
assert.Nil(t, err)
assertSQL(true)
})
}
func testNoteIndexer(t *testing.T, callback func(tx Transaction, dao *NoteIndexer)) {
testTransaction(t, func(tx Transaction) {
logger := &util.NullLogger
callback(tx, NewNoteIndexer(NewNoteDAO(tx, logger), NewCollectionDAO(tx, logger), logger))
})
}
func assertTagExistsOrNot(t *testing.T, tx Transaction, shouldExist bool, tag string) {
assertExistOrNot(t, tx, shouldExist, "SELECT id FROM collections WHERE kind = 'tag' AND name = ?", tag)
}
func assertTaggedOrNot(t *testing.T, tx Transaction, shouldBeTagged bool, noteId core.NoteId, tag string) {
assertExistOrNot(t, tx, shouldBeTagged, "SELECT id FROM notes_collections WHERE note_id = ? AND collection_id IS (SELECT id FROM collections WHERE kind = 'tag' AND name = ?)", noteId, tag)
}

@ -1,70 +0,0 @@
package sqlite
import (
"testing"
"github.com/go-testfixtures/testfixtures/v3"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/test/assert"
)
// testTransaction is an utility function used to test a SQLite transaction to
// the DB, which loads the default set of DB fixtures.
func testTransaction(t *testing.T, test func(tx Transaction)) {
testTransactionWithFixtures(t, opt.NewString("default"), test)
}
// testTransactionWithFixtures is an utility function used to test a SQLite transaction to
// the DB, which loads the given set of DB fixtures.
func testTransactionWithFixtures(t *testing.T, fixturesDir opt.String, test func(tx Transaction)) {
db, err := OpenInMemory()
assert.Nil(t, err)
_, err = db.Migrate()
assert.Nil(t, err)
if !fixturesDir.IsNull() {
fixtures, err := testfixtures.New(
testfixtures.Database(db.db),
testfixtures.Dialect("sqlite"),
testfixtures.Directory("fixtures/"+fixturesDir.String()),
// Necessary to work with an in-memory database.
testfixtures.DangerousSkipTestDatabaseCheck(),
)
assert.Nil(t, err)
err = fixtures.Load()
assert.Nil(t, err)
}
err = db.WithTransaction(func(tx Transaction) error {
test(tx)
return nil
})
assert.Nil(t, err)
}
func assertExistOrNot(t *testing.T, tx Transaction, shouldExist bool, sql string, args ...interface{}) {
if shouldExist {
assertExist(t, tx, sql, args...)
} else {
assertNotExist(t, tx, sql, args...)
}
}
func assertExist(t *testing.T, tx Transaction, sql string, args ...interface{}) {
if !exists(t, tx, sql, args...) {
t.Errorf("SQL query did not return any result: %s, with arguments %v", sql, args)
}
}
func assertNotExist(t *testing.T, tx Transaction, sql string, args ...interface{}) {
if exists(t, tx, sql, args...) {
t.Errorf("SQL query returned a result: %s, with arguments %v", sql, args)
}
}
func exists(t *testing.T, tx Transaction, sql string, args ...interface{}) bool {
var exists int
err := tx.QueryRow("SELECT EXISTS ("+sql+")", args...).Scan(&exists)
assert.Nil(t, err)
return exists == 1
}

@ -1,20 +0,0 @@
package term
import (
survey "github.com/AlecAivazis/survey/v2"
)
// Confirm is a shortcut to prompt a yes/no question to the user.
func (t *Terminal) Confirm(msg string, defaultAnswer bool) (confirmed, skipped bool) {
if !t.IsInteractive() {
return defaultAnswer, true
}
confirmed = false
prompt := &survey.Confirm{
Message: msg,
Default: defaultAnswer,
}
survey.AskOne(prompt, &confirmed)
return confirmed, false
}

@ -1,120 +0,0 @@
package term
import (
"fmt"
"github.com/fatih/color"
"github.com/mickael-menu/zk/core/style"
)
// Style implements style.Styler using ANSI escape codes to be used with a terminal.
func (t *Terminal) Style(text string, rules ...style.Rule) (string, error) {
if text == "" {
return text, nil
}
attrs, err := attributes(expandThemeAliases(rules))
if err != nil {
return "", err
}
if len(attrs) == 0 {
return text, nil
}
return color.New(attrs...).Sprint(text), nil
}
func (t *Terminal) MustStyle(text string, rules ...style.Rule) string {
text, err := t.Style(text, rules...)
if err != nil {
panic(err.Error())
}
return text
}
// FIXME: User config
var themeAliases = map[style.Rule][]style.Rule{
"title": {"bold", "yellow"},
"path": {"underline", "cyan"},
"term": {"red"},
"emphasis": {"bold", "cyan"},
"understate": {"faint"},
}
func expandThemeAliases(rules []style.Rule) []style.Rule {
expanded := make([]style.Rule, 0)
for _, rule := range rules {
aliases, ok := themeAliases[rule]
if ok {
aliases = expandThemeAliases(aliases)
for _, alias := range aliases {
expanded = append(expanded, alias)
}
} else {
expanded = append(expanded, rule)
}
}
return expanded
}
var attrsMapping = map[style.Rule]color.Attribute{
style.RuleBold: color.Bold,
style.RuleFaint: color.Faint,
style.RuleItalic: color.Italic,
style.RuleUnderline: color.Underline,
style.RuleBlink: color.BlinkSlow,
style.RuleReverse: color.ReverseVideo,
style.RuleHidden: color.Concealed,
style.RuleStrikethrough: color.CrossedOut,
style.RuleBlack: color.FgBlack,
style.RuleRed: color.FgRed,
style.RuleGreen: color.FgGreen,
style.RuleYellow: color.FgYellow,
style.RuleBlue: color.FgBlue,
style.RuleMagenta: color.FgMagenta,
style.RuleCyan: color.FgCyan,
style.RuleWhite: color.FgWhite,
style.RuleBlackBg: color.BgBlack,
style.RuleRedBg: color.BgRed,
style.RuleGreenBg: color.BgGreen,
style.RuleYellowBg: color.BgYellow,
style.RuleBlueBg: color.BgBlue,
style.RuleMagentaBg: color.BgMagenta,
style.RuleCyanBg: color.BgCyan,
style.RuleWhiteBg: color.BgWhite,
style.RuleBrightBlack: color.FgHiBlack,
style.RuleBrightRed: color.FgHiRed,
style.RuleBrightGreen: color.FgHiGreen,
style.RuleBrightYellow: color.FgHiYellow,
style.RuleBrightBlue: color.FgHiBlue,
style.RuleBrightMagenta: color.FgHiMagenta,
style.RuleBrightCyan: color.FgHiCyan,
style.RuleBrightWhite: color.FgHiWhite,
style.RuleBrightBlackBg: color.BgHiBlack,
style.RuleBrightRedBg: color.BgHiRed,
style.RuleBrightGreenBg: color.BgHiGreen,
style.RuleBrightYellowBg: color.BgHiYellow,
style.RuleBrightBlueBg: color.BgHiBlue,
style.RuleBrightMagentaBg: color.BgHiMagenta,
style.RuleBrightCyanBg: color.BgHiCyan,
style.RuleBrightWhiteBg: color.BgHiWhite,
}
func attributes(rules []style.Rule) ([]color.Attribute, error) {
attrs := make([]color.Attribute, 0)
for _, rule := range rules {
attr, ok := attrsMapping[rule]
if !ok {
return attrs, fmt.Errorf("unknown styling rule: %v", rule)
} else {
attrs = append(attrs, attr)
}
}
return attrs, nil
}

@ -1,26 +0,0 @@
package cmd
import (
"fmt"
"path/filepath"
"github.com/mickael-menu/zk/core/zk"
)
// Init creates a notebook in the given directory
type Init struct {
Directory string `arg optional type:"path" default:"." help:"Directory containing the notebook."`
}
func (cmd *Init) Run() error {
err := zk.Create(cmd.Directory)
if err == nil {
path, err := filepath.Abs(cmd.Directory)
if err != nil {
path = cmd.Directory
}
fmt.Printf("Initialized a notebook in %v\n", path)
}
return err
}

@ -1,99 +0,0 @@
package cmd
import (
"fmt"
"io"
"os"
"github.com/mickael-menu/zk/adapter"
"github.com/mickael-menu/zk/adapter/fzf"
"github.com/mickael-menu/zk/adapter/sqlite"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/strings"
)
// List displays notes matching a set of criteria.
type List struct {
Format string `group:format short:f placeholder:TEMPLATE help:"Pretty print the list using the given format."`
Delimiter string "group:format short:d default:\n help:\"Print notes delimited by the given separator.\""
Delimiter0 bool "group:format short:0 name:delimiter0 help:\"Print notes delimited by ASCII NUL characters. This is useful when used in conjunction with `xargs -0`.\""
NoPager bool `group:format short:P help:"Do not pipe output into a pager."`
Quiet bool `group:format short:q help:"Do not print the total number of notes found."`
Filtering
}
func (cmd *List) Run(container *adapter.Container) error {
if cmd.Delimiter0 {
cmd.Delimiter = "\x00"
}
zk, err := container.Zk()
if err != nil {
return err
}
opts, err := NewFinderOpts(zk, cmd.Filtering)
if err != nil {
return err
}
db, _, err := container.Database(false)
if err != nil {
return err
}
templates := container.TemplateLoader(container.Config.Note.Lang)
styler := container.Terminal
format := opt.NewNotEmptyString(cmd.Format)
formatter, err := note.NewFormatter(zk.Path, container.WorkingDir, format, templates, styler)
if err != nil {
return err
}
var notes []note.Match
err = db.WithTransaction(func(tx sqlite.Transaction) error {
finder := container.NoteFinder(tx, fzf.NoteFinderOpts{
AlwaysFilter: false,
PreviewCmd: container.Config.Tool.FzfPreview,
BasePath: zk.Path,
CurrentPath: container.WorkingDir,
})
notes, err = finder.Find(*opts)
return err
})
if err != nil {
if err == note.ErrCanceled {
return nil
}
return err
}
count := len(notes)
if count > 0 {
err = container.Paginate(cmd.NoPager, func(out io.Writer) error {
for i, note := range notes {
if i > 0 {
fmt.Fprint(out, cmd.Delimiter)
}
ft, err := formatter.Format(note)
if err != nil {
return err
}
fmt.Fprint(out, ft)
}
if cmd.Delimiter0 {
fmt.Fprint(out, "\x00")
}
return nil
})
}
if err == nil && !cmd.Quiet {
fmt.Fprintf(os.Stderr, "\n\nFound %d %s\n", count, strings.Pluralize("note", count))
}
return err
}

@ -1,80 +0,0 @@
package cmd
import (
"errors"
"fmt"
"github.com/mickael-menu/zk/adapter"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/os"
)
// New adds a new note to the notebook.
type New struct {
Directory string `arg optional default:"." help:"Directory in which to create the note."`
Title string `short:t placeholder:TITLE help:"Title of the new note."`
Group string `short:g placeholder:NAME help:"Name of the config group this note belongs to. Takes precedence over the config of the directory."`
Extra map[string]string ` help:"Extra variables passed to the templates." mapsep:","`
Template string `type:path placeholder:PATH help:"Custom template used to render the note."`
PrintPath bool `short:p help:"Print the path of the created note instead of editing it."`
}
func (cmd *New) ConfigOverrides() zk.ConfigOverrides {
return zk.ConfigOverrides{
Group: opt.NewNotEmptyString(cmd.Group),
BodyTemplatePath: opt.NewNotEmptyString(cmd.Template),
Extra: cmd.Extra,
}
}
func (cmd *New) Run(container *adapter.Container) error {
zk, err := container.Zk()
if err != nil {
return err
}
dir, err := zk.RequireDirAt(cmd.Directory, cmd.ConfigOverrides())
if err != nil {
return err
}
content, err := os.ReadStdinPipe()
if err != nil {
return err
}
opts := note.CreateOpts{
Config: container.Config,
Dir: *dir,
Title: opt.NewNotEmptyString(cmd.Title),
Content: content,
}
file, err := note.Create(opts, container.TemplateLoader(dir.Config.Note.Lang), container.Date)
if err != nil {
var noteExists note.ErrNoteExists
if !errors.As(err, &noteExists) {
return err
}
if confirmed, _ := container.Terminal.Confirm(
fmt.Sprintf("%s already exists, do you want to edit this note instead?", noteExists.Name),
true,
); !confirmed {
// abort...
return nil
}
file = noteExists.Path
}
if cmd.PrintPath {
fmt.Printf("%+v\n", file)
return nil
} else {
return note.Edit(zk, file)
}
}

@ -1,19 +0,0 @@
package core
type NoteId int64
func (id NoteId) IsValid() bool {
return id > 0
}
type CollectionId int64
func (id CollectionId) IsValid() bool {
return id > 0
}
type NoteCollectionId int64
func (id NoteCollectionId) IsValid() bool {
return id > 0
}

@ -1,175 +0,0 @@
package note
import (
"fmt"
"path/filepath"
"time"
"github.com/mickael-menu/zk/core/templ"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/date"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/os"
"github.com/mickael-menu/zk/util/paths"
"github.com/mickael-menu/zk/util/rand"
)
// CreateOpts holds the options to create a new note.
type CreateOpts struct {
// Current configuration.
Config zk.Config
// Parent directory for the new note.
Dir zk.Dir
// Title of the note.
Title opt.String
// Initial content of the note, which will be injected in the template.
Content opt.String
}
// ErrNoteExists is an error returned when a note already exists with the
// filename generated by Create().
type ErrNoteExists struct {
Name string
Path string
}
func (e ErrNoteExists) Error() string {
return fmt.Sprintf("%s: note already exists", e.Path)
}
// Create generates a new note from the given options.
// Returns the path of the newly created note.
func Create(
opts CreateOpts,
templateLoader templ.Loader,
date date.Provider,
) (string, error) {
wrap := errors.Wrapperf("new note")
filenameTemplate, err := templateLoader.Load(opts.Dir.Config.Note.FilenameTemplate)
if err != nil {
return "", err
}
var bodyTemplate templ.Renderer = templ.NullRenderer
if templatePath := opts.Dir.Config.Note.BodyTemplatePath.Unwrap(); templatePath != "" {
absPath, ok := opts.Config.LocateTemplate(templatePath)
if !ok {
return "", wrap(fmt.Errorf("%s: cannot find template", templatePath))
}
bodyTemplate, err = templateLoader.LoadFile(absPath)
if err != nil {
return "", wrap(err)
}
}
createdNote, err := create(opts, createDeps{
filenameTemplate: filenameTemplate,
bodyTemplate: bodyTemplate,
genId: rand.NewIDGenerator(opts.Dir.Config.Note.IDOptions),
validatePath: validatePath,
now: date.Date(),
})
if err != nil {
return "", wrap(err)
}
err = paths.WriteString(createdNote.path, createdNote.content)
if err != nil {
return "", wrap(err)
}
return createdNote.path, nil
}
func validatePath(path string) (bool, error) {
exists, err := paths.Exists(path)
return !exists, err
}
type createdNote struct {
path string
content string
}
// renderContext holds the placeholder values which will be expanded in the templates.
type renderContext struct {
ID string `handlebars:"id"`
Title string
Content string
Dir string
Filename string
FilenameStem string `handlebars:"filename-stem"`
Extra map[string]string
Now time.Time
Env map[string]string
}
type createDeps struct {
filenameTemplate templ.Renderer
bodyTemplate templ.Renderer
genId func() string
validatePath func(path string) (bool, error)
now time.Time
}
func create(
opts CreateOpts,
deps createDeps,
) (*createdNote, error) {
context := renderContext{
Title: opts.Title.OrString(opts.Dir.Config.Note.DefaultTitle).Unwrap(),
Content: opts.Content.Unwrap(),
Dir: opts.Dir.Name,
Extra: opts.Dir.Config.Extra,
Now: deps.now,
Env: os.Env(),
}
path, context, err := genPath(context, opts.Dir, deps)
if err != nil {
return nil, err
}
content, err := deps.bodyTemplate.Render(context)
if err != nil {
return nil, err
}
return &createdNote{path: path, content: content}, nil
}
func genPath(
context renderContext,
dir zk.Dir,
deps createDeps,
) (string, renderContext, error) {
var err error
var filename string
var path string
for i := 0; i < 50; i++ {
context.ID = deps.genId()
filename, err = deps.filenameTemplate.Render(context)
if err != nil {
return "", context, err
}
filename = filename + "." + dir.Config.Note.Extension
path = filepath.Join(dir.Path, filename)
validPath, err := deps.validatePath(path)
if err != nil {
return "", context, err
} else if validPath {
context.Filename = filepath.Base(path)
context.FilenameStem = paths.FilenameStem(path)
return path, context, nil
}
}
return "", context, ErrNoteExists{
Name: filepath.Join(dir.Name, filename),
Path: path,
}
}

@ -1,174 +0,0 @@
package note
import (
"fmt"
"testing"
"github.com/mickael-menu/zk/core/templ"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/os"
"github.com/mickael-menu/zk/util/test/assert"
)
func TestCreate(t *testing.T) {
filenameTemplate := NewRendererSpyString("filename")
bodyTemplate := NewRendererSpyString("body")
res, err := create(
CreateOpts{
Dir: zk.Dir{
Name: "log",
Path: "/test/log",
Config: zk.GroupConfig{
Note: zk.NoteConfig{
Extension: "md",
},
Extra: map[string]string{
"hello": "world",
},
},
},
Title: opt.NewString("Note title"),
Content: opt.NewString("Note content"),
},
createDeps{
filenameTemplate: filenameTemplate,
bodyTemplate: bodyTemplate,
genId: func() string { return "abc" },
validatePath: func(path string) (bool, error) { return true, nil },
now: Now,
},
)
// Check the created note.
assert.Nil(t, err)
assert.Equal(t, res, &createdNote{
path: "/test/log/filename.md",
content: "body",
})
// Check that the templates received the proper render contexts.
assert.Equal(t, filenameTemplate.Contexts, []interface{}{renderContext{
ID: "abc",
Title: "Note title",
Content: "Note content",
Dir: "log",
Extra: map[string]string{
"hello": "world",
},
Now: Now,
Env: os.Env(),
}})
assert.Equal(t, bodyTemplate.Contexts, []interface{}{renderContext{
ID: "abc",
Title: "Note title",
Content: "Note content",
Dir: "log",
Filename: "filename.md",
FilenameStem: "filename",
Extra: map[string]string{
"hello": "world",
},
Now: Now,
Env: os.Env(),
}})
}
func TestCreateTriesUntilValidPath(t *testing.T) {
filenameTemplate := NewRendererSpy(func(context interface{}) string {
return context.(renderContext).ID
})
bodyTemplate := NewRendererSpyString("body")
res, err := create(
CreateOpts{
Dir: zk.Dir{
Name: "log",
Path: "/test/log",
Config: zk.GroupConfig{
Note: zk.NoteConfig{
Extension: "md",
},
},
},
Title: opt.NewString("Note title"),
},
createDeps{
filenameTemplate: filenameTemplate,
bodyTemplate: bodyTemplate,
genId: incrementingID(),
validatePath: func(path string) (bool, error) {
return path == "/test/log/3.md", nil
},
now: Now,
},
)
// Check the created note.
assert.Nil(t, err)
assert.Equal(t, res, &createdNote{
path: "/test/log/3.md",
content: "body",
})
assert.Equal(t, filenameTemplate.Contexts, []interface{}{
renderContext{
ID: "1",
Title: "Note title",
Dir: "log",
Now: Now,
Env: os.Env(),
},
renderContext{
ID: "2",
Title: "Note title",
Dir: "log",
Now: Now,
Env: os.Env(),
},
renderContext{
ID: "3",
Title: "Note title",
Dir: "log",
Now: Now,
Env: os.Env(),
},
})
}
func TestCreateErrorWhenNoValidPaths(t *testing.T) {
_, err := create(
CreateOpts{
Dir: zk.Dir{
Name: "log",
Path: "/test/log",
Config: zk.GroupConfig{
Note: zk.NoteConfig{
Extension: "md",
},
},
},
},
createDeps{
filenameTemplate: templ.RendererFunc(func(context interface{}) (string, error) {
return "filename", nil
}),
bodyTemplate: templ.NullRenderer,
genId: func() string { return "abc" },
validatePath: func(path string) (bool, error) { return false, nil },
now: Now,
},
)
assert.Err(t, err, "/test/log/filename.md: note already exists")
}
// incrementingID returns a generator of incrementing string ID.
func incrementingID() func() string {
i := 0
return func() string {
i++
return fmt.Sprintf("%d", i)
}
}

@ -1,41 +0,0 @@
package note
import (
"fmt"
"os"
"strings"
"github.com/kballard/go-shellquote"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/errors"
executil "github.com/mickael-menu/zk/util/exec"
"github.com/mickael-menu/zk/util/opt"
osutil "github.com/mickael-menu/zk/util/os"
)
// Edit starts the editor with the notes at given paths.
func Edit(zk *zk.Zk, paths ...string) error {
editor := editor(zk)
if editor.IsNull() {
return fmt.Errorf("no editor set in config")
}
// /dev/tty is restored as stdin, in case the user used a pipe to feed
// initial note content to `zk new`. Without this, Vim doesn't work
// properly in this case.
// See https://github.com/mickael-menu/zk/issues/4
cmd := executil.CommandFromString(editor.String() + " " + shellquote.Join(paths...) + " </dev/tty")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return errors.Wrapf(cmd.Run(), "failed to launch editor: %s %s", editor, strings.Join(paths, " "))
}
// editor returns the editor command to use to edit a note.
func editor(zk *zk.Zk) opt.String {
return osutil.GetOptEnv("ZK_EDITOR").
Or(zk.Config.Tool.Editor).
Or(osutil.GetOptEnv("VISUAL")).
Or(osutil.GetOptEnv("EDITOR"))
}

@ -1,63 +0,0 @@
package note
import (
"os"
"testing"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/test/assert"
)
func TestEditorUsesZkEditorFirst(t *testing.T) {
os.Setenv("ZK_EDITOR", "zk-editor")
os.Setenv("VISUAL", "visual")
os.Setenv("EDITOR", "editor")
zk := zk.Zk{Config: zk.Config{
Tool: zk.ToolConfig{
Editor: opt.NewString("custom-editor"),
},
}}
assert.Equal(t, editor(&zk), opt.NewString("zk-editor"))
}
func TestEditorFallsbackOnUserConfig(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Setenv("VISUAL", "visual")
os.Setenv("EDITOR", "editor")
zk := zk.Zk{Config: zk.Config{
Tool: zk.ToolConfig{
Editor: opt.NewString("custom-editor"),
},
}}
assert.Equal(t, editor(&zk), opt.NewString("custom-editor"))
}
func TestEditorFallsbackOnVisual(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Setenv("VISUAL", "visual")
os.Setenv("EDITOR", "editor")
zk := zk.Zk{}
assert.Equal(t, editor(&zk), opt.NewString("visual"))
}
func TestEditorFallsbackOnEditor(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Unsetenv("VISUAL")
os.Setenv("EDITOR", "editor")
zk := zk.Zk{}
assert.Equal(t, editor(&zk), opt.NewString("editor"))
}
func TestEditorWhenUnset(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
zk := zk.Zk{}
assert.Equal(t, editor(&zk), opt.NullString)
}

@ -1,69 +0,0 @@
package note
import (
"testing"
"github.com/mickael-menu/zk/util/test/assert"
)
func TestSorterFromString(t *testing.T) {
test := func(str string, expectedField SortField, expectedAscending bool) {
actual, err := SorterFromString(str)
assert.Nil(t, err)
assert.Equal(t, actual, Sorter{Field: expectedField, Ascending: expectedAscending})
}
test("c", SortCreated, false)
test("c+", SortCreated, true)
test("created", SortCreated, false)
test("created-", SortCreated, false)
test("created+", SortCreated, true)
test("m", SortModified, false)
test("modified", SortModified, false)
test("modified+", SortModified, true)
test("p", SortPath, true)
test("path", SortPath, true)
test("path-", SortPath, false)
test("t", SortTitle, true)
test("title", SortTitle, true)
test("title-", SortTitle, false)
test("r", SortRandom, true)
test("random", SortRandom, true)
test("random-", SortRandom, false)
test("wc", SortWordCount, true)
test("word-count", SortWordCount, true)
test("word-count-", SortWordCount, false)
_, err := SorterFromString("foobar")
assert.Err(t, err, "foobar: unknown sorting term")
}
func TestSortersFromStrings(t *testing.T) {
test := func(strs []string, expected []Sorter) {
actual, err := SortersFromStrings(strs)
assert.Nil(t, err)
assert.Equal(t, actual, expected)
}
test([]string{}, []Sorter{})
test([]string{"created"}, []Sorter{
{Field: SortCreated, Ascending: false},
})
// It is parsed in reverse order to be able to override sort criteria set
// in aliases.
test([]string{"c+", "title", "random"}, []Sorter{
{Field: SortRandom, Ascending: true},
{Field: SortTitle, Ascending: true},
{Field: SortCreated, Ascending: true},
})
_, err := SortersFromStrings([]string{"c", "foobar"})
assert.Err(t, err, "foobar: unknown sorting term")
}

@ -1,132 +0,0 @@
package note
import (
"path/filepath"
"regexp"
"strings"
"time"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/core/templ"
"github.com/mickael-menu/zk/util/opt"
)
// Formatter formats notes to be printed on the screen.
type Formatter struct {
basePath string
currentPath string
renderer templ.Renderer
// Regex replacement for a term marked in a snippet.
snippetTermReplacement string
}
// NewFormatter creates a Formatter from a given format template.
//
// The absolute path to the notebook (basePath) and the working directory
// (currentPath) are used to make the path of each note relative to the working
// directory.
func NewFormatter(basePath string, currentPath string, format opt.String, templates templ.Loader, styler style.Styler) (*Formatter, error) {
template := resolveFormatTemplate(format)
renderer, err := templates.Load(template)
if err != nil {
return nil, err
}
termRepl, err := styler.Style("$1", style.RuleTerm)
if err != nil {
return nil, err
}
return &Formatter{
basePath: basePath,
currentPath: currentPath,
renderer: renderer,
snippetTermReplacement: termRepl,
}, nil
}
func resolveFormatTemplate(format opt.String) string {
templ, ok := formatTemplates[format.OrString("short").Unwrap()]
if !ok {
templ = format.String()
// Replace raw \n and \t by actual newlines and tabs in user format.
templ = strings.ReplaceAll(templ, "\\n", "\n")
templ = strings.ReplaceAll(templ, "\\t", "\t")
}
return templ
}
var formatTemplates = map[string]string{
"path": `{{path}}`,
"oneline": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`,
"short": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{list snippets}}`,
"medium": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
{{list snippets}}`,
"long": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
{{list snippets}}`,
"full": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
Tags: {{join tags ", "}}
{{prepend " " body}}
`,
}
var termRegex = regexp.MustCompile(`<zk:match>(.*?)</zk:match>`)
// Format formats a note to be printed on the screen.
func (f *Formatter) Format(match Match) (string, error) {
path, err := filepath.Rel(f.currentPath, filepath.Join(f.basePath, match.Path))
if err != nil {
return "", err
}
snippets := make([]string, 0)
for _, snippet := range match.Snippets {
snippets = append(snippets, termRegex.ReplaceAllString(snippet, f.snippetTermReplacement))
}
return f.renderer.Render(formatRenderContext{
Path: path,
Title: match.Title,
Lead: match.Lead,
Body: match.Body,
Snippets: snippets,
Tags: match.Tags,
RawContent: match.RawContent,
WordCount: match.WordCount,
Metadata: match.Metadata.Metadata,
Created: match.Created,
Modified: match.Modified,
Checksum: match.Checksum,
})
}
type formatRenderContext struct {
Path string
Title string
Lead string
Body string
Snippets []string
RawContent string `handlebars:"raw-content"`
WordCount int `handlebars:"word-count"`
Tags []string
Metadata map[string]interface{}
Created time.Time
Modified time.Time
Checksum string
Env map[string]string
}

@ -1,167 +0,0 @@
package note
import (
"testing"
"time"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/test/assert"
)
func TestEmptyFormat(t *testing.T) {
f, _ := newFormatter(t, opt.NewString(""))
res, err := f.Format(Match{})
assert.Nil(t, err)
assert.Equal(t, res, "")
}
func TestDefaultFormat(t *testing.T) {
f, _ := newFormatter(t, opt.NullString)
res, err := f.Format(Match{})
assert.Nil(t, err)
assert.Equal(t, res, `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{list snippets}}`)
}
func TestFormats(t *testing.T) {
test := func(format string, expected string) {
f, _ := newFormatter(t, opt.NewString(format))
actual, err := f.Format(Match{})
assert.Nil(t, err)
assert.Equal(t, actual, expected)
}
// Known formats
test("path", `{{path}}`)
test("oneline", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`)
test("short", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{list snippets}}`)
test("medium", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
{{list snippets}}`)
test("long", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
{{list snippets}}`)
test("full", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
Tags: {{join tags ", "}}
{{prepend " " body}}
`)
// Known formats are case sensitive.
test("Path", "Path")
// Custom formats are used literally.
test("{{title}}", "{{title}}")
// \n and \t in custom formats are expanded.
test(`{{title}}\t{{path}}\n{{snippet}}`, "{{title}}\t{{path}}\n{{snippet}}")
}
func TestFormatRenderContext(t *testing.T) {
f, templs := newFormatter(t, opt.NewString("path"))
_, err := f.Format(Match{
Snippets: []string{"Note snippet"},
Metadata: Metadata{
Path: "dir/note.md",
Title: "Note title",
Lead: "Lead paragraph",
Body: "Note body",
RawContent: "Raw content",
WordCount: 42,
Created: Now,
Modified: Now.Add(48 * time.Hour),
Checksum: "Note checksum",
},
})
assert.Nil(t, err)
// Check that the template was provided with the proper information in the
// render context.
assert.Equal(t, templs.Contexts, []interface{}{
formatRenderContext{
Path: "dir/note.md",
Title: "Note title",
Lead: "Lead paragraph",
Body: "Note body",
Snippets: []string{"Note snippet"},
RawContent: "Raw content",
WordCount: 42,
Created: Now,
Modified: Now.Add(48 * time.Hour),
Checksum: "Note checksum",
},
})
}
func TestFormatPath(t *testing.T) {
test := func(basePath, currentPath, path string, expected string) {
f, templs := newFormatterWithPaths(t, basePath, currentPath, opt.NullString)
_, err := f.Format(Match{
Metadata: Metadata{Path: path},
})
assert.Nil(t, err)
assert.Equal(t, templs.Contexts, []interface{}{
formatRenderContext{
Path: expected,
Snippets: []string{},
},
})
}
// Check that the path is relative to the current directory.
test("", "", "note.md", "note.md")
test("", "", "dir/note.md", "dir/note.md")
test("/abs/zk", "/abs/zk", "note.md", "note.md")
test("/abs/zk", "/abs/zk", "dir/note.md", "dir/note.md")
test("/abs/zk", "/abs/zk/dir", "note.md", "../note.md")
test("/abs/zk", "/abs/zk/dir", "dir/note.md", "note.md")
test("/abs/zk", "/abs", "note.md", "zk/note.md")
test("/abs/zk", "/abs", "dir/note.md", "zk/dir/note.md")
}
func TestFormatStylesSnippetTerm(t *testing.T) {
test := func(snippet string, expected string) {
f, templs := newFormatter(t, opt.NullString)
_, err := f.Format(Match{
Snippets: []string{snippet},
})
assert.Nil(t, err)
assert.Equal(t, templs.Contexts, []interface{}{
formatRenderContext{
Path: ".",
Snippets: []string{expected},
},
})
}
test("Hello world!", "Hello world!")
test("Hello <zk:match>world</zk:match>!", "Hello term(world)!")
test("Hello <zk:match>world</zk:match> with <zk:match>several matches</zk:match>!", "Hello term(world) with term(several matches)!")
test("Hello <zk:match>world</zk:match> with <zk:match>several<zk:match> matches</zk:match>!", "Hello term(world) with term(several<zk:match> matches)!")
}
func newFormatter(t *testing.T, format opt.String) (*Formatter, *TemplLoaderSpy) {
return newFormatterWithPaths(t, "", "", format)
}
func newFormatterWithPaths(t *testing.T, basePath, currentPath string, format opt.String) (*Formatter, *TemplLoaderSpy) {
loader := NewTemplLoaderSpy()
styler := &StylerMock{}
formatter, err := NewFormatter(basePath, currentPath, format, loader, styler)
assert.Nil(t, err)
return formatter, loader
}

@ -1,192 +0,0 @@
package note
import (
"crypto/sha256"
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"time"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/paths"
strutil "github.com/mickael-menu/zk/util/strings"
"github.com/relvacode/iso8601"
"gopkg.in/djherbis/times.v1"
)
// Metadata holds information about a particular note.
type Metadata struct {
Path string
Title string
Lead string
Body string
RawContent string
WordCount int
Links []Link
Tags []string
Metadata map[string]interface{}
Created time.Time
Modified time.Time
Checksum string
}
// IndexingStats holds metrics about an indexing process.
type IndexingStats struct {
SourceCount int
AddedCount int
ModifiedCount int
RemovedCount int
Duration time.Duration
}
// String implements Stringer
func (s IndexingStats) String() string {
return fmt.Sprintf(`Indexed %d %v in %v
+ %d added
~ %d modified
- %d removed`,
s.SourceCount,
strutil.Pluralize("note", s.SourceCount),
s.Duration.Round(500*time.Millisecond),
s.AddedCount, s.ModifiedCount, s.RemovedCount,
)
}
// Indexer persists the notes index.
type Indexer interface {
// Indexed returns the list of indexed note file metadata.
Indexed() (<-chan paths.Metadata, error)
// Add indexes a new note from its metadata.
Add(metadata Metadata) (core.NoteId, error)
// Update updates the metadata of an already indexed note.
Update(metadata Metadata) error
// Remove deletes a note from the index.
Remove(path string) error
}
// Index indexes the content of the notes in the given notebook.
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")
stats := IndexingStats{}
startTime := time.Now()
// FIXME: Use Extension defined in each DirConfig.
source := paths.Walk(zk.Path, zk.Config.Note.Extension, logger)
target, err := indexer.Indexed()
if err != nil {
return stats, wrap(err)
}
count, err := paths.Diff(source, target, force, func(change paths.DiffChange) error {
callback(change)
switch change.Kind {
case paths.DiffAdded:
stats.AddedCount += 1
metadata, err := metadata(change.Path, zk, parser)
if err == nil {
_, err = indexer.Add(metadata)
}
logger.Err(err)
case paths.DiffModified:
stats.ModifiedCount += 1
metadata, err := metadata(change.Path, zk, parser)
if err == nil {
err = indexer.Update(metadata)
}
logger.Err(err)
case paths.DiffRemoved:
stats.RemovedCount += 1
err := indexer.Remove(change.Path)
logger.Err(err)
}
return nil
})
stats.SourceCount = count
stats.Duration = time.Since(startTime)
return stats, wrap(err)
}
// metadata retrieves note metadata for the given file.
func metadata(path string, zk *zk.Zk, parser Parser) (Metadata, error) {
metadata := Metadata{
Path: path,
Links: []Link{},
Tags: []string{},
}
absPath := filepath.Join(zk.Path, path)
content, err := ioutil.ReadFile(absPath)
if err != nil {
return metadata, err
}
contentStr := string(content)
contentParts, err := parser.Parse(contentStr)
if err != nil {
return metadata, err
}
metadata.Title = contentParts.Title.String()
metadata.Lead = contentParts.Lead.String()
metadata.Body = contentParts.Body.String()
metadata.RawContent = contentStr
metadata.WordCount = len(strings.Fields(contentStr))
metadata.Links = make([]Link, 0)
metadata.Tags = contentParts.Tags
metadata.Metadata = contentParts.Metadata
metadata.Checksum = fmt.Sprintf("%x", sha256.Sum256(content))
for _, link := range contentParts.Links {
if !strutil.IsURL(link.Href) {
// Make the href relative to the notebook 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)
if err != nil {
return metadata, err
}
metadata.Modified = times.ModTime().UTC()
metadata.Created = creationDateFrom(metadata.Metadata, times)
return metadata, nil
}
func creationDateFrom(metadata map[string]interface{}, times times.Timespec) time.Time {
// Read the creation date from the YAML frontmatter `date` key.
if dateVal, ok := metadata["date"]; ok {
if dateStr, ok := dateVal.(string); ok {
if time, err := iso8601.ParseString(dateStr); err == nil {
return time
}
// Omitting the `T` is common
if time, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil {
return time
}
if time, err := time.Parse("2006-01-02 15:04", dateStr); err == nil {
return time
}
}
}
if times.HasBirthTime() {
return times.BirthTime().UTC()
}
return time.Now().UTC()
}

@ -1,59 +0,0 @@
package note
import (
"github.com/mickael-menu/zk/util/opt"
)
type Content struct {
// Title is the heading of the note.
Title opt.String
// Lead is the opening paragraph or section of the note.
Lead opt.String
// Body is the content of the note, including the Lead but without the Title.
Body opt.String
// Tags is the list of tags found in the note content.
Tags []string
// Links is the list of outbound links found in the note.
Links []Link
// Additional metadata. For example, extracted from a YAML frontmatter.
Metadata map[string]interface{}
}
// Link links a note to another note or an external resource.
type Link struct {
Title string
Href string
External bool
Rels []string
Snippet string
SnippetStart int
SnippetEnd int
}
// LinkRelation defines the relationship between a link's source and target.
type LinkRelation string
const (
// LinkRelationDown defines the target note as a child of the source.
LinkRelationDown LinkRelation = "down"
// LinkRelationDown defines the target note as a parent of the source.
LinkRelationUp LinkRelation = "up"
)
type Parser interface {
Parse(source string) (*Content, error)
}
// Collection holds metadata about a note collection.
type Collection struct {
Kind CollectionKind
Name string
NoteCount int
}
// CollectionKind defines a kind of note collection, such as tags.
type CollectionKind string
const (
CollectionKindTag CollectionKind = "tag"
)

@ -1,77 +0,0 @@
package note
import (
"fmt"
"time"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/core/templ"
)
var Now = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC)
// TemplLoaderSpy implements templ.Loader and saves the render contexts
// provided to the templates it creates.
//
// The generated Renderer returns the template used to create them without
// modification.
type TemplLoaderSpy struct {
Contexts []interface{}
}
func NewTemplLoaderSpy() *TemplLoaderSpy {
return &TemplLoaderSpy{
Contexts: make([]interface{}, 0),
}
}
func (l *TemplLoaderSpy) Load(template string) (templ.Renderer, error) {
return NewRendererSpy(func(context interface{}) string {
l.Contexts = append(l.Contexts, context)
return template
}), nil
}
func (l *TemplLoaderSpy) LoadFile(path string) (templ.Renderer, error) {
panic("not implemented")
}
// RendererSpy implements templ.Renderer and saves the provided render contexts.
type RendererSpy struct {
Result func(interface{}) string
Contexts []interface{}
}
func NewRendererSpy(result func(interface{}) string) *RendererSpy {
return &RendererSpy{
Contexts: make([]interface{}, 0),
Result: result,
}
}
func NewRendererSpyString(result string) *RendererSpy {
return &RendererSpy{
Contexts: make([]interface{}, 0),
Result: func(_ interface{}) string { return result },
}
}
func (m *RendererSpy) Render(context interface{}) (string, error) {
m.Contexts = append(m.Contexts, context)
return m.Result(context), nil
}
// StylerMock implements core.Styler by doing the transformation:
// "hello", "red" -> "red(hello)"
type StylerMock struct{}
func (s *StylerMock) Style(text string, rules ...style.Rule) (string, error) {
return s.MustStyle(text, rules...), nil
}
func (s *StylerMock) MustStyle(text string, rules ...style.Rule) string {
for _, rule := range rules {
text = fmt.Sprintf("%s(%s)", rule, text)
}
return text
}

@ -1,80 +0,0 @@
package style
// Styler stylizes text according to predefined styling rules.
//
// A rule key can be either semantic, e.g. "title" or explicit, e.g. "red".
type Styler interface {
Style(text string, rules ...Rule) (string, error)
MustStyle(text string, rules ...Rule) string
}
// Rule is a key representing a single styling rule.
type Rule string
// Predefined styling rules.
var (
// Title of a note.
RuleTitle = Rule("title")
// Path to notebook file.
RulePath = Rule("path")
// Searched for term in a note.
RuleTerm = Rule("term")
// Element to emphasize, for example the short version of a prompt response: [y]es.
RuleEmphasis = Rule("emphasis")
// Element to understate, for example the content of the note in fzf.
RuleUnderstate = Rule("understate")
RuleBold = Rule("bold")
RuleItalic = Rule("italic")
RuleFaint = Rule("faint")
RuleUnderline = Rule("underline")
RuleStrikethrough = Rule("strikethrough")
RuleBlink = Rule("blink")
RuleReverse = Rule("reverse")
RuleHidden = Rule("hidden")
RuleBlack = Rule("black")
RuleRed = Rule("red")
RuleGreen = Rule("green")
RuleYellow = Rule("yellow")
RuleBlue = Rule("blue")
RuleMagenta = Rule("magenta")
RuleCyan = Rule("cyan")
RuleWhite = Rule("white")
RuleBlackBg = Rule("black-bg")
RuleRedBg = Rule("red-bg")
RuleGreenBg = Rule("green-bg")
RuleYellowBg = Rule("yellow-bg")
RuleBlueBg = Rule("blue-bg")
RuleMagentaBg = Rule("magenta-bg")
RuleCyanBg = Rule("cyan-bg")
RuleWhiteBg = Rule("white-bg")
RuleBrightBlack = Rule("bright-black")
RuleBrightRed = Rule("bright-red")
RuleBrightGreen = Rule("bright-green")
RuleBrightYellow = Rule("bright-yellow")
RuleBrightBlue = Rule("bright-blue")
RuleBrightMagenta = Rule("bright-magenta")
RuleBrightCyan = Rule("bright-cyan")
RuleBrightWhite = Rule("bright-white")
RuleBrightBlackBg = Rule("bright-black-bg")
RuleBrightRedBg = Rule("bright-red-bg")
RuleBrightGreenBg = Rule("bright-green-bg")
RuleBrightYellowBg = Rule("bright-yellow-bg")
RuleBrightBlueBg = Rule("bright-blue-bg")
RuleBrightMagentaBg = Rule("bright-magenta-bg")
RuleBrightCyanBg = Rule("bright-cyan-bg")
RuleBrightWhiteBg = Rule("bright-white-bg")
)
// NullStyler is a Styler with no styling rules.
var NullStyler = nullStyler{}
type nullStyler struct{}
func (s nullStyler) Style(text string, rule ...Rule) (string, error) {
return text, nil
}

@ -1,28 +0,0 @@
package templ
// Loader parses a given string template.
type Loader interface {
Load(template string) (Renderer, error)
LoadFile(path string) (Renderer, error)
}
// Renderer produces a string using a given context.
type Renderer interface {
Render(context interface{}) (string, error)
}
// RendererFunc is an adapter to use a function as a Renderer.
type RendererFunc func(context interface{}) (string, error)
func (f RendererFunc) Render(context interface{}) (string, error) {
return f(context)
}
// NullRenderer is a Renderer always returning an empty string.
var NullRenderer = nullRenderer{}
type nullRenderer struct{}
func (t nullRenderer) Render(context interface{}) (string, error) {
return "", nil
}

@ -1,320 +0,0 @@
package zk
import (
"os"
"path/filepath"
"testing"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/test/assert"
)
func TestDBPath(t *testing.T) {
wd, _ := os.Getwd()
zk := &Zk{Path: wd}
assert.Equal(t, zk.DBPath(), filepath.Join(wd, ".zk/notebook.db"))
}
func TestRootDir(t *testing.T) {
wd, _ := os.Getwd()
zk := &Zk{Path: wd}
assert.Equal(t, zk.RootDir(), Dir{
Name: "",
Path: wd,
Config: zk.Config.RootGroupConfig(),
})
}
func TestRelativePathFromGivenPath(t *testing.T) {
// The tests are relative to the working directory, for convenience.
wd, _ := os.Getwd()
zk := &Zk{Path: wd}
for path, expected := range map[string]string{
"log": "log",
"log/sub": "log/sub",
"log/sub/..": "log",
"log/sub/../sub": "log/sub",
filepath.Join(wd, "log"): "log",
filepath.Join(wd, "log/sub"): "log/sub",
} {
actual, err := zk.RelPath(path)
assert.Nil(t, err)
assert.Equal(t, actual, expected)
}
}
func TestDirAtGivenPath(t *testing.T) {
// The tests are relative to the working directory, for convenience.
wd, _ := os.Getwd()
zk := &Zk{Path: wd}
for path, name := range map[string]string{
"log": "log",
"log/sub": "log/sub",
"log/sub/..": "log",
"log/sub/../sub": "log/sub",
filepath.Join(wd, "log"): "log",
filepath.Join(wd, "log/sub"): "log/sub",
} {
actual, err := zk.DirAt(path)
assert.Nil(t, err)
assert.Equal(t, actual.Name, name)
assert.Equal(t, actual.Path, filepath.Join(wd, name))
}
}
func TestDirAtOutsideNotebook(t *testing.T) {
wd, _ := os.Getwd()
zk := &Zk{Path: wd}
for _, path := range []string{
"..",
"../..",
"/tmp",
} {
_, err := zk.DirAt(path)
assert.Err(t, err, "path is outside the notebook")
}
}
// When requesting the root directory `.`, the config is the default one.
func TestDirAtRoot(t *testing.T) {
wd, _ := os.Getwd()
zk := Zk{
Path: wd,
Config: Config{
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("default.note"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetAlphanum,
Case: CaseLower,
},
},
Groups: map[string]GroupConfig{
"log": {
Note: NoteConfig{
FilenameTemplate: "{{date}}.md",
},
},
},
Extra: map[string]string{
"hello": "world",
},
},
}
dir, err := zk.DirAt(".")
assert.Nil(t, err)
assert.Equal(t, dir.Name, "")
assert.Equal(t, dir.Path, wd)
assert.Equal(t, dir.Config, GroupConfig{
Paths: []string{},
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("default.note"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetAlphanum,
Case: CaseLower,
},
},
Extra: map[string]string{
"hello": "world",
},
})
}
// When requesting a directory, the matching GroupConfig will be returned.
func TestDirAtFindsGroup(t *testing.T) {
wd, _ := os.Getwd()
zk := Zk{
Path: wd,
Config: Config{
Groups: map[string]GroupConfig{
"ref": {
Paths: []string{"ref"},
},
"log": {
Paths: []string{"journal/daily", "journal/weekly"},
},
"glob": {
Paths: []string{"glob/*"},
},
},
},
}
dir, err := zk.DirAt("ref")
assert.Nil(t, err)
assert.Equal(t, dir.Config.Paths, []string{"ref"})
dir, err = zk.DirAt("journal/weekly")
assert.Nil(t, err)
assert.Equal(t, dir.Config.Paths, []string{"journal/daily", "journal/weekly"})
dir, err = zk.DirAt("glob/qwfpgj")
assert.Nil(t, err)
assert.Equal(t, dir.Config.Paths, []string{"glob/*"})
dir, err = zk.DirAt("glob/qwfpgj/no")
assert.Nil(t, err)
assert.Equal(t, dir.Config.Paths, []string{})
dir, err = zk.DirAt("glob")
assert.Nil(t, err)
assert.Equal(t, dir.Config.Paths, []string{})
}
// Modifying the GroupConfig of the returned Dir should not modify the global config.
func TestDirAtReturnsClonedConfig(t *testing.T) {
wd, _ := os.Getwd()
zk := Zk{
Path: wd,
Config: Config{
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("default.note"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetAlphanum,
Case: CaseLower,
},
},
Extra: map[string]string{
"hello": "world",
},
},
}
dir, err := zk.DirAt(".")
assert.Nil(t, err)
dir.Config.Note.FilenameTemplate = "modified"
dir.Config.Note.BodyTemplatePath = opt.NewString("modified")
dir.Config.Note.IDOptions.Length = 41
dir.Config.Note.IDOptions.Charset = CharsetNumbers
dir.Config.Note.IDOptions.Case = CaseUpper
dir.Config.Extra["test"] = "modified"
assert.Equal(t, zk.Config.RootGroupConfig(), GroupConfig{
Paths: []string{},
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("default.note"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetAlphanum,
Case: CaseLower,
},
},
Extra: map[string]string{
"hello": "world",
},
})
}
func TestDirAtWithOverrides(t *testing.T) {
wd, _ := os.Getwd()
zk := Zk{
Path: wd,
Config: Config{
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("default.note"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetLetters,
Case: CaseUpper,
},
},
Extra: map[string]string{
"hello": "world",
},
Groups: map[string]GroupConfig{
"group": {
Paths: []string{"group-path"},
Note: NoteConfig{
BodyTemplatePath: opt.NewString("group.note"),
},
Extra: map[string]string{},
},
},
},
}
dir, err := zk.DirAt(".",
ConfigOverrides{
BodyTemplatePath: opt.NewString("overridden-template"),
Extra: map[string]string{
"hello": "overridden",
"additional": "value",
},
},
ConfigOverrides{
Extra: map[string]string{
"additional": "value2",
"additional2": "value3",
},
},
)
assert.Nil(t, err)
assert.Equal(t, dir.Config, GroupConfig{
Paths: []string{},
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("overridden-template"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetLetters,
Case: CaseUpper,
},
},
Extra: map[string]string{
"hello": "overridden",
"additional": "value2",
"additional2": "value3",
},
})
// Overriding the group will select a different group config.
dir, err = zk.DirAt(".", ConfigOverrides{Group: opt.NewString("group")})
assert.Nil(t, err)
assert.Equal(t, dir.Config, GroupConfig{
Paths: []string{"group-path"},
Note: NoteConfig{
BodyTemplatePath: opt.NewString("group.note"),
},
Extra: map[string]string{},
})
// An unknown group override returns an error.
_, err = zk.DirAt(".", ConfigOverrides{Group: opt.NewString("foobar")})
assert.Err(t, err, "foobar: group not find in the config file")
// Check that the original config was not modified.
assert.Equal(t, zk.Config.RootGroupConfig(), GroupConfig{
Paths: []string{},
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
BodyTemplatePath: opt.NewString("default.note"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetLetters,
Case: CaseUpper,
},
},
Extra: map[string]string{
"hello": "world",
},
})
}

@ -0,0 +1,47 @@
package editor
import (
"fmt"
"os"
"strings"
"github.com/kballard/go-shellquote"
"github.com/mickael-menu/zk/internal/util/errors"
executil "github.com/mickael-menu/zk/internal/util/exec"
"github.com/mickael-menu/zk/internal/util/opt"
osutil "github.com/mickael-menu/zk/internal/util/os"
)
// Editor represents an external editor able to edit the notes.
type Editor struct {
editor string
}
// NewEditor creates a new Editor from the given editor user setting or the
// matching environment variables.
func NewEditor(editor opt.String) (*Editor, error) {
editor = osutil.GetOptEnv("ZK_EDITOR").
Or(editor).
Or(osutil.GetOptEnv("VISUAL")).
Or(osutil.GetOptEnv("EDITOR"))
if editor.IsNull() {
return nil, fmt.Errorf("no editor set in config")
}
return &Editor{editor.Unwrap()}, nil
}
// Open launches the editor with the notes at given paths.
func (e *Editor) Open(paths ...string) error {
// /dev/tty is restored as stdin, in case the user used a pipe to feed
// initial note content to `zk new`. Without this, Vim doesn't work
// properly in this case.
// See https://github.com/mickael-menu/zk/issues/4
cmd := executil.CommandFromString(e.editor + " " + shellquote.Join(paths...) + " </dev/tty")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return errors.Wrapf(cmd.Run(), "failed to launch editor: %s %s", e.editor, strings.Join(paths, " "))
}

@ -0,0 +1,59 @@
package editor
import (
"os"
"testing"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestEditorUsesZkEditorFirst(t *testing.T) {
os.Setenv("ZK_EDITOR", "zk-editor")
os.Setenv("VISUAL", "visual")
os.Setenv("EDITOR", "editor")
editor, err := NewEditor(opt.NewString("custom-editor"))
assert.Nil(t, err)
assert.Equal(t, editor.editor, "zk-editor")
}
func TestEditorFallsbackOnUserConfig(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Setenv("VISUAL", "visual")
os.Setenv("EDITOR", "editor")
editor, err := NewEditor(opt.NewString("custom-editor"))
assert.Nil(t, err)
assert.Equal(t, editor.editor, "custom-editor")
}
func TestEditorFallsbackOnVisual(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Setenv("VISUAL", "visual")
os.Setenv("EDITOR", "editor")
editor, err := NewEditor(opt.NullString)
assert.Nil(t, err)
assert.Equal(t, editor.editor, "visual")
}
func TestEditorFallsbackOnEditor(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Unsetenv("VISUAL")
os.Setenv("EDITOR", "editor")
editor, err := NewEditor(opt.NullString)
assert.Nil(t, err)
assert.Equal(t, editor.editor, "editor")
}
func TestEditorFailsWhenUnset(t *testing.T) {
os.Unsetenv("ZK_EDITOR")
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
editor, err := NewEditor(opt.NullString)
assert.Err(t, err, "no editor set in config")
assert.Nil(t, editor)
}

@ -0,0 +1,133 @@
package fs
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/mickael-menu/zk/internal/util"
)
// FileStorage implements the port core.FileStorage.
type FileStorage struct {
// Current working directory.
WorkingDir string
logger util.Logger
}
// NewFileStorage creates a new instance of FileStorage using the given working
// directory as reference point for relative paths.
func NewFileStorage(workingDir string, logger util.Logger) (*FileStorage, error) {
if workingDir == "" {
var err error
workingDir, err = os.Getwd()
if err != nil {
return nil, err
}
}
return &FileStorage{workingDir, logger}, nil
}
func (fs *FileStorage) Abs(path string) (string, error) {
var err error
if !filepath.IsAbs(path) {
path = filepath.Join(fs.WorkingDir, path)
path, err = filepath.Abs(path)
if err != nil {
return path, err
}
}
return path, nil
}
func (fs *FileStorage) Rel(path string) (string, error) {
return filepath.Rel(fs.WorkingDir, path)
}
func (fs *FileStorage) Canonical(path string) string {
path = filepath.Clean(path)
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
fs.logger.Err(err)
} else {
path = resolvedPath
}
return path
}
func (fs *FileStorage) FileExists(path string) (bool, error) {
fi, err := fs.fileInfo(path)
if err != nil {
return false, err
} else {
return fi != nil && (*fi).Mode().IsRegular(), nil
}
}
func (fs *FileStorage) DirExists(path string) (bool, error) {
fi, err := fs.fileInfo(path)
if err != nil {
return false, err
} else {
return fi != nil && (*fi).Mode().IsDir(), nil
}
}
func (fs *FileStorage) fileInfo(path string) (*os.FileInfo, error) {
if fi, err := os.Stat(path); err == nil {
return &fi, nil
} else if os.IsNotExist(err) {
return nil, nil
} else {
return nil, err
}
}
func (fs *FileStorage) IsDescendantOf(dir string, path string) (bool, error) {
dir, err := fs.Abs(dir)
if err != nil {
return false, err
}
dir = fs.Canonical(dir)
path, err = fs.Abs(path)
if err != nil {
return false, err
}
path = fs.Canonical(path)
path, err = filepath.Rel(dir, path)
if err != nil {
return false, err
}
return !strings.HasPrefix(path, ".."), nil
}
func (fs *FileStorage) Read(path string) ([]byte, error) {
return ioutil.ReadFile(path)
}
func (fs *FileStorage) Write(path string, content []byte) error {
dir := filepath.Dir(path)
if dir != "." && dir != ".." {
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
return err
}
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(content)
return err
}

@ -8,12 +8,14 @@ import (
"strings"
"sync"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/opt"
stringsutil "github.com/mickael-menu/zk/util/strings"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/opt"
stringsutil "github.com/mickael-menu/zk/internal/util/strings"
)
// ErrCancelled is returned when the user cancelled fzf.
var ErrCancelled = errors.New("cancelled")
// fzf exit codes
var (
exitInterrupted = 130
@ -142,7 +144,7 @@ func New(opts Opts) (*Fzf, error) {
exitErr, ok := err.(*exec.ExitError)
switch {
case ok && exitErr.ExitCode() == exitInterrupted:
f.err = note.ErrCanceled
f.err = ErrCancelled
case ok && exitErr.ExitCode() == exitNoMatch:
break
default:

@ -0,0 +1,129 @@
package fzf
import (
"fmt"
"os"
"path/filepath"
"github.com/mickael-menu/zk/internal/adapter/term"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/opt"
stringsutil "github.com/mickael-menu/zk/internal/util/strings"
)
// NoteFilter uses fzf to filter interactively a set of notes.
type NoteFilter struct {
opts NoteFilterOpts
terminal *term.Terminal
}
// NoteFilterOpts holds the configuration for the fzf notes filtering.
//
// The absolute path to the notebook (NotebookDir) and the working directory
// (WorkingDir) are used to make the path of each note relative to the working
// directory.
type NoteFilterOpts struct {
// Indicates whether the filtering is interactive. If not, fzf is bypassed.
Interactive bool
// Indicates whether fzf is opened for every query, even if empty.
AlwaysFilter bool
// Preview command to run when selecting a note.
PreviewCmd opt.String
// When non null, a "create new note from query" binding will be added to
// fzf to create a note in this directory.
NewNoteDir *core.Dir
// Absolute path to the notebook.
NotebookDir string
// Absolute path to the working directory.
WorkingDir string
}
func NewNoteFilter(opts NoteFilterOpts, terminal *term.Terminal) *NoteFilter {
return &NoteFilter{
opts: opts,
terminal: terminal,
}
}
// Apply filters the given notes with fzf.
func (f *NoteFilter) Apply(notes []core.ContextualNote) ([]core.ContextualNote, error) {
selectedNotes := make([]core.ContextualNote, 0)
relPaths := []string{}
if !f.opts.Interactive || !f.terminal.IsInteractive() || (!f.opts.AlwaysFilter && len(notes) == 0) {
return notes, nil
}
for _, note := range notes {
absPath := filepath.Join(f.opts.NotebookDir, note.Path)
relPath, err := filepath.Rel(f.opts.WorkingDir, absPath)
if err != nil {
return selectedNotes, err
}
relPaths = append(relPaths, relPath)
}
zkBin, err := os.Executable()
if err != nil {
return selectedNotes, err
}
bindings := []Binding{}
if dir := f.opts.NewNoteDir; dir != nil {
suffix := ""
if dir.Name != "" {
suffix = " in " + dir.Name + "/"
}
bindings = append(bindings, Binding{
Keys: "Ctrl-N",
Description: "create a note with the query as title" + suffix,
Action: fmt.Sprintf("abort+execute(%s new %s --title {q} < /dev/tty > /dev/tty)", zkBin, dir.Path),
})
}
previewCmd := f.opts.PreviewCmd.OrString("cat {-1}").Unwrap()
if previewCmd != "" {
// The note paths will be relative to the current path, so we need to
// move there otherwise the preview command will fail.
previewCmd = `cd "` + f.opts.WorkingDir + `" && ` + previewCmd
}
fzf, err := New(Opts{
PreviewCmd: opt.NewNotEmptyString(previewCmd),
Padding: 2,
Bindings: bindings,
})
if err != nil {
return selectedNotes, err
}
for i, note := range notes {
title := note.Title
if title == "" {
title = relPaths[i]
}
fzf.Add([]string{
f.terminal.MustStyle(title, core.StyleYellow),
f.terminal.MustStyle(stringsutil.JoinLines(note.Body), core.StyleUnderstate),
f.terminal.MustStyle(relPaths[i], core.StyleUnderstate),
})
}
selection, err := fzf.Selection()
if err != nil {
return selectedNotes, err
}
for _, s := range selection {
path := s[len(s)-1]
for i, m := range notes {
if relPaths[i] == path {
selectedNotes = append(selectedNotes, m)
}
}
}
return selectedNotes, nil
}

@ -0,0 +1,153 @@
package handlebars
import (
"fmt"
"html"
"path/filepath"
"github.com/aymerick/raymond"
"github.com/mickael-menu/zk/internal/adapter/handlebars/helpers"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/paths"
)
func Init(supportsUTF8 bool, logger util.Logger) {
helpers.RegisterConcat()
helpers.RegisterDate(logger)
helpers.RegisterJoin()
helpers.RegisterList(supportsUTF8)
helpers.RegisterPrepend(logger)
helpers.RegisterShell(logger)
}
// Template renders a parsed handlebars template.
type Template struct {
template *raymond.Template
styler core.Styler
}
// Styler implements core.Template.
func (t *Template) Styler() core.Styler {
return t.styler
}
// Render implements core.Template.
func (t *Template) Render(context interface{}) (string, error) {
res, err := t.template.Exec(context)
if err != nil {
return "", errors.Wrap(err, "render template failed")
}
return html.UnescapeString(res), nil
}
// Loader loads and holds parsed handlebars templates.
type Loader struct {
strings map[string]*Template
files map[string]*Template
lookupPaths []string
lang string
styler core.Styler
logger util.Logger
}
type LoaderOpts struct {
// LookupPaths is used to resolve relative template paths.
LookupPaths []string
Lang string
Styler core.Styler
Logger util.Logger
}
// NewLoader creates a new instance of Loader.
//
func NewLoader(opts LoaderOpts) *Loader {
return &Loader{
strings: make(map[string]*Template),
files: make(map[string]*Template),
lookupPaths: opts.LookupPaths,
lang: opts.Lang,
styler: opts.Styler,
logger: opts.Logger,
}
}
// LoadTemplate implements core.TemplateLoader.
func (l *Loader) LoadTemplate(content string) (core.Template, error) {
wrap := errors.Wrapperf("load template failed")
// Already loaded?
template, ok := l.strings[content]
if ok {
return template, nil
}
// Load new template.
vendorTempl, err := raymond.Parse(content)
if err != nil {
return nil, wrap(err)
}
template = l.newTemplate(vendorTempl)
l.strings[content] = template
return template, nil
}
// LoadTemplateAt implements core.TemplateLoader.
func (l *Loader) LoadTemplateAt(path string) (core.Template, error) {
wrap := errors.Wrapper("load template file failed")
path, ok := l.locateTemplate(path)
if !ok {
return nil, wrap(fmt.Errorf("cannot find template at %s", path))
}
// Already loaded?
template, ok := l.files[path]
if ok {
return template, nil
}
// Load new template.
vendorTempl, err := raymond.ParseFile(path)
if err != nil {
return nil, wrap(err)
}
template = l.newTemplate(vendorTempl)
l.files[path] = template
return template, nil
}
// locateTemplate returns the absolute path for the given template path, by
// looking for it in the templates directories registered in this Config.
func (l *Loader) locateTemplate(path string) (string, bool) {
if path == "" {
return "", false
}
exists := func(path string) bool {
exists, err := paths.Exists(path)
return exists && err == nil
}
if filepath.IsAbs(path) {
return path, exists(path)
}
for _, dir := range l.lookupPaths {
if candidate := filepath.Join(dir, path); exists(candidate) {
return candidate, true
}
}
return path, false
}
func (l *Loader) newTemplate(vendorTempl *raymond.Template) *Template {
vendorTempl.RegisterHelpers(map[string]interface{}{
"style": helpers.NewStyleHelper(l.styler, l.logger),
"slug": helpers.NewSlugHelper(l.lang, l.logger),
})
return &Template{vendorTempl, l.styler}
}

@ -2,28 +2,31 @@ package handlebars
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/fixtures"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/fixtures"
"github.com/mickael-menu/zk/internal/util/paths"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func init() {
Init("en", true, &util.NullLogger, &styler{})
Init(true, &util.NullLogger)
}
// styler is a test double for core.Styler
// "hello", "red" -> "red(hello)"
type styler struct{}
func (s *styler) Style(text string, rules ...style.Rule) (string, error) {
func (s *styler) Style(text string, rules ...core.Style) (string, error) {
return s.MustStyle(text, rules...), nil
}
func (s *styler) MustStyle(text string, rules ...style.Rule) string {
func (s *styler) MustStyle(text string, rules ...core.Style) string {
for _, rule := range rules {
text = fmt.Sprintf("%s(%s)", rule, text)
}
@ -31,9 +34,9 @@ func (s *styler) MustStyle(text string, rules ...style.Rule) string {
}
func testString(t *testing.T, template string, context interface{}, expected string) {
sut := NewLoader()
sut := testLoader([]string{})
templ, err := sut.Load(template)
templ, err := sut.LoadTemplate(template)
assert.Nil(t, err)
actual, err := templ.Render(context)
@ -42,9 +45,9 @@ func testString(t *testing.T, template string, context interface{}, expected str
}
func testFile(t *testing.T, name string, context interface{}, expected string) {
sut := NewLoader()
sut := testLoader([]string{})
templ, err := sut.LoadFile(fixtures.Path(name))
templ, err := sut.LoadTemplateAt(fixtures.Path(name))
assert.Nil(t, err)
actual, err := templ.Render(context)
@ -52,6 +55,45 @@ func testFile(t *testing.T, name string, context interface{}, expected string) {
assert.Equal(t, actual, expected)
}
func TestLookupPaths(t *testing.T) {
root := fmt.Sprintf("/tmp/zk-test-%d", time.Now().Unix())
os.Remove(root)
path1 := filepath.Join(root, "1")
os.MkdirAll(path1, os.ModePerm)
path2 := filepath.Join(root, "1")
os.MkdirAll(filepath.Join(path2, "subdir"), os.ModePerm)
sut := testLoader([]string{path1, path2})
test := func(path string, expected string) {
tpl, err := sut.LoadTemplateAt(path)
assert.Nil(t, err)
res, err := tpl.Render(nil)
assert.Nil(t, err)
assert.Equal(t, res, expected)
}
test1 := filepath.Join(path1, "test1.tpl")
tpl1, err := sut.LoadTemplateAt(test1)
assert.Err(t, err, "cannot find template at "+test1)
assert.Nil(t, tpl1)
paths.WriteString(test1, "Test 1")
test(test1, "Test 1") // absolute
test("test1.tpl", "Test 1") // relative
test2 := filepath.Join(path2, "test2.tpl")
paths.WriteString(test2, "Test 2")
test(test2, "Test 2") // absolute
test("test2.tpl", "Test 2") // relative
test3 := filepath.Join(path2, "subdir/test3.tpl")
paths.WriteString(test3, "Test 3")
test(test3, "Test 3") // absolute
test("subdir/test3.tpl", "Test 3") // relative
}
func TestRenderString(t *testing.T) {
testString(t,
"Goodbye, {{name}}",
@ -183,3 +225,12 @@ func TestStyleHelper(t *testing.T) {
// block
testString(t, "{{#style 'single'}}A multiline\ntext{{/style}}", nil, "single(A multiline\ntext)")
}
func testLoader(lookupPaths []string) *Loader {
return NewLoader(LoaderOpts{
LookupPaths: lookupPaths,
Lang: "en",
Styler: &styler{},
Logger: &util.NullLogger,
})
}

@ -5,7 +5,7 @@ import (
"github.com/aymerick/raymond"
"github.com/lestrrat-go/strftime"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/internal/util"
"github.com/rvflash/elapsed"
)

@ -2,8 +2,8 @@ package helpers
import (
"github.com/aymerick/raymond"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/strings"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/strings"
)
// RegisterPrepend registers a {{prepend}} template helper which prepend a

@ -4,8 +4,8 @@ import (
"strings"
"github.com/aymerick/raymond"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/exec"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/exec"
)
// RegisterShell registers the {{sh}} template helper, which runs shell commands.

@ -3,15 +3,15 @@ package helpers
import (
"github.com/aymerick/raymond"
"github.com/gosimple/slug"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/internal/util"
)
// RegisterSlug registers a {{slug}} template helper to slugify text.
// NewSlugHelper creates a new template helper to slugify text.
//
// {{slug "This will be slugified!"}} -> this-will-be-slugified
// {{#slug}}This will be slugified!{{/slug}} -> this-will-be-slugified
func RegisterSlug(lang string, logger util.Logger) {
raymond.RegisterHelper("slug", func(opt interface{}) string {
func NewSlugHelper(lang string, logger util.Logger) interface{} {
return func(opt interface{}) string {
switch arg := opt.(type) {
case *raymond.Options:
return slug.MakeLang(arg.Fn(), lang)
@ -21,5 +21,5 @@ func RegisterSlug(lang string, logger util.Logger) {
logger.Printf("the {{slug}} template helper is expecting a string as argument, received: %v", opt)
return ""
}
})
}
}

@ -4,20 +4,20 @@ import (
"strings"
"github.com/aymerick/raymond"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
)
// RegisterStyle register the {{style}} template helpers which stylizes the
// text input according to predefined styling rules.
// NewStyleHelper creates a new template helper which stylizes the text input
// according to predefined styling rules.
//
// {{style "date" created}}
// {{#style "red"}}Hello, world{{/style}}
func RegisterStyle(styler style.Styler, logger util.Logger) {
func NewStyleHelper(styler core.Styler, logger util.Logger) interface{} {
style := func(keys string, text string) string {
rules := make([]style.Rule, 0)
rules := make([]core.Style, 0)
for _, key := range strings.Fields(keys) {
rules = append(rules, style.Rule(key))
rules = append(rules, core.Style(key))
}
res, err := styler.Style(text, rules...)
if err != nil {
@ -28,7 +28,7 @@ func RegisterStyle(styler style.Styler, logger util.Logger) {
}
}
raymond.RegisterHelper("style", func(rules string, opt interface{}) string {
return func(rules string, opt interface{}) string {
switch arg := opt.(type) {
case *raymond.Options:
return style(rules, arg.Fn())
@ -38,5 +38,5 @@ func RegisterStyle(styler style.Styler, logger util.Logger) {
logger.Printf("the {{style}} template helper is expecting a string as input, received: %v", opt)
return ""
}
})
}
}

@ -0,0 +1,38 @@
package lsp
import (
"fmt"
"github.com/tliron/kutil/logging"
)
// glspLogger is a Logger wrapping the GLSP one.
// Can be used to change the active logger during runtime.
type glspLogger struct {
log logging.Logger
}
func newGlspLogger(log logging.Logger) *glspLogger {
return &glspLogger{log}
}
func (l *glspLogger) Printf(format string, v ...interface{}) {
l.log.Debugf("zk: "+format, v...)
}
func (l *glspLogger) Println(vs ...interface{}) {
message := "zk: "
for i, v := range vs {
if i > 0 {
message += ", "
}
message += fmt.Sprint(v)
}
l.log.Debug(message)
}
func (l *glspLogger) Err(err error) {
if err != nil {
l.log.Debugf("zk: warning: %v", err)
}
}

@ -7,13 +7,11 @@ import (
"path/filepath"
"strings"
"github.com/mickael-menu/zk/adapter"
"github.com/mickael-menu/zk/adapter/sqlite"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/opt"
strutil "github.com/mickael-menu/zk/util/strings"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/opt"
strutil "github.com/mickael-menu/zk/internal/util/strings"
"github.com/tliron/glsp"
protocol "github.com/tliron/glsp/protocol_3_16"
glspserv "github.com/tliron/glsp/server"
@ -24,8 +22,10 @@ import (
// Server holds the state of the Language Server.
type Server struct {
server *glspserv.Server
container *adapter.Container
notebooks *core.NotebookStore
documents map[protocol.DocumentUri]*document
fs core.FileStorage
logger util.Logger
}
// ServerOpts holds the options to create a new Server.
@ -33,11 +33,14 @@ type ServerOpts struct {
Name string
Version string
LogFile opt.String
Container *adapter.Container
Logger *util.ProxyLogger
Notebooks *core.NotebookStore
FS core.FileStorage
}
// NewServer creates a new Server instance.
func NewServer(opts ServerOpts) *Server {
fs := opts.FS
debug := !opts.LogFile.IsNull()
if debug {
logging.Configure(10, opts.LogFile.Value)
@ -47,8 +50,16 @@ func NewServer(opts ServerOpts) *Server {
handler := protocol.Handler{}
server := &Server{
server: glspserv.NewServer(&handler, opts.Name, debug),
container: opts.Container,
notebooks: opts.Notebooks,
documents: map[string]*document{},
fs: fs,
}
// Redirect zk's logger to GLSP's to avoid breaking the JSON-RPC protocol
// with unwanted output.
if opts.Logger != nil {
opts.Logger.Logger = newGlspLogger(server.server.Log)
server.logger = opts.Logger
}
var clientCapabilities protocol.ClientCapabilities
@ -66,8 +77,6 @@ func NewServer(opts ServerOpts) *Server {
workspace.addFolder(*params.RootPath)
}
server.container.OpenNotebook(workspace.folders)
// To see the logs with coc.nvim, run :CocCommand workspace.showOutput
// https://github.com/neoclide/coc.nvim/wiki/Debug-language-server#using-output-channel
if params.Trace != nil {
@ -77,30 +86,19 @@ func NewServer(opts ServerOpts) *Server {
capabilities := handler.CreateServerCapabilities()
capabilities.HoverProvider = true
zk, err := server.container.Zk()
if err == nil {
capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental
capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{
ResolveProvider: boolPtr(true),
}
triggerChars := []string{"["}
// Setup tag completion trigger characters
if zk.Config.Format.Markdown.Hashtags {
triggerChars = append(triggerChars, "#")
}
if zk.Config.Format.Markdown.ColonTags {
triggerChars = append(triggerChars, ":")
}
capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental
capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{
ResolveProvider: boolPtr(true),
}
capabilities.CompletionProvider = &protocol.CompletionOptions{
TriggerCharacters: triggerChars,
}
triggerChars := []string{"[", "#", ":"}
capabilities.DefinitionProvider = boolPtr(true)
capabilities.CompletionProvider = &protocol.CompletionOptions{
TriggerCharacters: triggerChars,
}
capabilities.DefinitionProvider = boolPtr(true)
return protocol.InitializeResult{
Capabilities: capabilities,
ServerInfo: &protocol.InitializeResultServerInfo{
@ -140,8 +138,10 @@ func NewServer(opts ServerOpts) *Server {
return nil
}
path := fs.Canonical(strings.TrimPrefix(params.TextDocument.URI, "file://"))
server.documents[params.TextDocument.URI] = &document{
Path: strings.TrimPrefix(params.TextDocument.URI, "file://"),
Path: path,
Content: params.TextDocument.Text,
Log: server.server.Log,
}
@ -170,17 +170,31 @@ func NewServer(opts ServerOpts) *Server {
handler.TextDocumentCompletion = func(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) {
triggerChar := params.Context.TriggerCharacter
if params.Context.TriggerKind != protocol.CompletionTriggerKindTriggerCharacter || triggerChar == nil {
return nil, nil
}
switch *triggerChar {
case "#", ":":
return server.buildTagCompletionList(*triggerChar)
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return nil, nil
}
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
switch *triggerChar {
case "#":
if notebook.Config.Format.Markdown.Hashtags {
return server.buildTagCompletionList(notebook, "#")
}
case ":":
if notebook.Config.Format.Markdown.ColonTags {
return server.buildTagCompletionList(notebook, ":")
}
case "[":
return server.buildLinkCompletionList(params)
return server.buildLinkCompletionList(doc, notebook, params)
}
return nil, nil
@ -192,29 +206,21 @@ func NewServer(opts ServerOpts) *Server {
return nil, nil
}
zk, err := server.container.Zk()
if err != nil {
link, err := doc.DocumentLinkAt(params.Position)
if link == nil || err != nil {
return nil, err
}
db, _, err := server.container.Database(false)
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
var target string
err = db.WithTransaction(func(tx sqlite.Transaction) error {
finder := sqlite.NewNoteDAO(tx, server.container.Logger)
link, err := doc.DocumentLinkAt(params.Position)
if link == nil || err != nil {
return err
}
target, err = server.targetForHref(link.Href, doc, zk.Path, finder)
return err
})
target, err := server.targetForHref(link.Href, doc, notebook)
if err != nil || target == "" || strutil.IsURL(target) {
return nil, err
}
target = strings.TrimPrefix(target, "file://")
contents, err := ioutil.ReadFile(target)
if err != nil {
@ -232,41 +238,31 @@ func NewServer(opts ServerOpts) *Server {
handler.TextDocumentDocumentLink = func(context *glsp.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return []protocol.DocumentLink{}, nil
return nil, nil
}
zk, err := server.container.Zk()
links, err := doc.DocumentLinks()
if err != nil {
return nil, err
}
db, _, err := server.container.Database(false)
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
var documentLinks []protocol.DocumentLink
err = db.WithTransaction(func(tx sqlite.Transaction) error {
finder := sqlite.NewNoteDAO(tx, server.container.Logger)
links, err := doc.DocumentLinks()
if err != nil {
return err
documentLinks := []protocol.DocumentLink{}
for _, link := range links {
target, err := server.targetForHref(link.Href, doc, notebook)
if target == "" || err != nil {
continue
}
for _, link := range links {
target, err := server.targetForHref(link.Href, doc, zk.Path, finder)
if target == "" || err != nil {
continue
}
documentLinks = append(documentLinks, protocol.DocumentLink{
Range: link.Range,
Target: &target,
})
}
return nil
})
documentLinks = append(documentLinks, protocol.DocumentLink{
Range: link.Range,
Target: &target,
})
}
return documentLinks, err
}
@ -274,30 +270,20 @@ func NewServer(opts ServerOpts) *Server {
handler.TextDocumentDefinition = func(context *glsp.Context, params *protocol.DefinitionParams) (interface{}, error) {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return []protocol.DocumentLink{}, nil
return nil, nil
}
zk, err := server.container.Zk()
if err != nil {
link, err := doc.DocumentLinkAt(params.Position)
if link == nil || err != nil {
return nil, err
}
db, _, err := server.container.Database(false)
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
var link *documentLink
var target string
err = db.WithTransaction(func(tx sqlite.Transaction) error {
finder := sqlite.NewNoteDAO(tx, server.container.Logger)
link, err = doc.DocumentLinkAt(params.Position)
if link == nil || err != nil {
return err
}
target, err = server.targetForHref(link.Href, doc, zk.Path, finder)
return err
})
target, err := server.targetForHref(link.Href, doc, notebook)
if link == nil || target == "" || err != nil {
return nil, err
}
@ -319,25 +305,29 @@ func NewServer(opts ServerOpts) *Server {
return server
}
func (s *Server) notebookOf(doc *document) (*core.Notebook, error) {
return s.notebooks.Open(doc.Path)
}
// targetForHref returns the LSP documentUri for the note at the given HREF.
func (s *Server) targetForHref(href string, doc *document, basePath string, finder note.Finder) (string, error) {
func (s *Server) targetForHref(href string, doc *document, notebook *core.Notebook) (string, error) {
if strutil.IsURL(href) {
return href, nil
} else {
path := filepath.Clean(filepath.Join(filepath.Dir(doc.Path), href))
path, err := filepath.Rel(basePath, path)
path, err := filepath.Rel(notebook.Path, path)
if err != nil {
return "", errors.Wrapf(err, "failed to resolve href: %s", href)
}
note, err := finder.FindByHref(path)
note, err := notebook.FindByHref(path)
if err != nil {
s.server.Log.Errorf("findByHref(%s): %s", href, err.Error())
s.logger.Printf("findByHref(%s): %s", href, err.Error())
return "", err
}
if note == nil {
return "", nil
}
return "file://" + filepath.Join(basePath, note.Path), nil
return "file://" + filepath.Join(notebook.Path, note.Path), nil
}
}
@ -346,21 +336,8 @@ func (s *Server) Run() error {
return errors.Wrap(s.server.RunStdio(), "lsp")
}
func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.CompletionItem, error) {
zk, err := s.container.Zk()
if err != nil {
return nil, err
}
db, _, err := s.container.Database(false)
if err != nil {
return nil, err
}
var tags []note.Collection
err = db.WithTransaction(func(tx sqlite.Transaction) error {
tags, err = sqlite.NewCollectionDAO(tx, s.container.Logger).FindAll(note.CollectionKindTag)
return err
})
func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar string) ([]protocol.CompletionItem, error) {
tags, err := notebook.FindCollections(core.CollectionKindTag)
if err != nil {
return nil, err
}
@ -369,7 +346,7 @@ func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.Completi
for _, tag := range tags {
items = append(items, protocol.CompletionItem{
Label: tag.Name,
InsertText: s.buildInsertForTag(tag.Name, triggerChar, zk.Config),
InsertText: s.buildInsertForTag(tag.Name, triggerChar, notebook.Config),
Detail: stringPtr(fmt.Sprintf("%d %s", tag.NoteCount, strutil.Pluralize("note", tag.NoteCount))),
})
}
@ -377,7 +354,7 @@ func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.Completi
return items, nil
}
func (s *Server) buildInsertForTag(name string, triggerChar string, config zk.Config) *string {
func (s *Server) buildInsertForTag(name string, triggerChar string, config core.Config) *string {
switch triggerChar {
case ":":
name += ":"
@ -393,27 +370,8 @@ func (s *Server) buildInsertForTag(name string, triggerChar string, config zk.Co
return &name
}
func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
zk, err := s.container.Zk()
if err != nil {
return nil, err
}
doc, ok := s.documents[params.TextDocument.URI]
if !ok {
return nil, nil
}
db, _, err := s.container.Database(false)
if err != nil {
return nil, err
}
var notes []note.Match
err = db.WithTransaction(func(tx sqlite.Transaction) error {
finder := sqlite.NewNoteDAO(tx, s.container.Logger)
notes, err = finder.Find(note.FinderOpts{})
return err
})
func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
notes, err := notebook.FindNotes(core.NoteFindOpts{})
if err != nil {
return nil, err
}
@ -422,7 +380,7 @@ func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]p
for _, note := range notes {
items = append(items, protocol.CompletionItem{
Label: note.Title,
TextEdit: s.buildTextEditForLink(zk, note, doc, params.Position),
TextEdit: s.buildTextEditForLink(notebook, note, doc, params.Position),
Documentation: protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: note.RawContent,
@ -433,11 +391,12 @@ func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]p
return items, nil
}
func (s *Server) buildTextEditForLink(zk *zk.Zk, note note.Match, document *document, pos protocol.Position) interface{} {
func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position) interface{} {
isWikiLink := (document.LookBehind(pos, 2) == "[[")
var text string
path := filepath.Join(zk.Path, note.Path)
path := filepath.Join(notebook.Path, note.Path)
path = s.fs.Canonical(path)
path, err := filepath.Rel(filepath.Dir(document.Path), path)
if err != nil {
path = note.Path

@ -3,7 +3,7 @@ package extensions
import (
"strings"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/internal/core"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
@ -38,7 +38,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
var (
href string
label string
rel note.LinkRelation
rel core.LinkRelation
)
var (
@ -65,7 +65,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
if closed {
// Supports trailing hash syntax for Neuron's Folgezettel, e.g. [[id]]#
if char == '#' {
rel = note.LinkRelationDown
rel = core.LinkRelationDown
}
break
}
@ -74,7 +74,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
switch char {
// Supports leading hash syntax for Neuron's Folgezettel, e.g. #[[id]]
case '#':
rel = note.LinkRelationUp
rel = core.LinkRelationUp
continue
case '[':
openerCharCount += 1
@ -104,7 +104,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
closed = true
// Neuron's legacy [[[Folgezettel]]].
if closerCharCount == 3 {
rel = note.LinkRelationDown
rel = core.LinkRelationDown
}
}
continue

@ -6,11 +6,11 @@ import (
"regexp"
"strings"
"github.com/mickael-menu/zk/adapter/markdown/extensions"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util/opt"
strutil "github.com/mickael-menu/zk/util/strings"
"github.com/mickael-menu/zk/util/yaml"
"github.com/mickael-menu/zk/internal/adapter/markdown/extensions"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/opt"
strutil "github.com/mickael-menu/zk/internal/util/strings"
"github.com/mickael-menu/zk/internal/util/yaml"
"github.com/mvdan/xurls"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
@ -60,9 +60,9 @@ func NewParser(options ParserOpts) *Parser {
}
}
// Parse implements note.Parse.
func (p *Parser) Parse(source string) (*note.Content, error) {
bytes := []byte(source)
// Parse implements core.NoteParser.
func (p *Parser) Parse(content string) (*core.ParsedNote, error) {
bytes := []byte(content)
context := parser.NewContext()
root := p.md.Parser().Parse(
@ -91,7 +91,7 @@ func (p *Parser) Parse(source string) (*note.Content, error) {
return nil, err
}
return &note.Content{
return &core.ParsedNote{
Title: title,
Body: body,
Lead: parseLead(body),
@ -208,8 +208,8 @@ func parseTags(frontmatter frontmatter, root ast.Node, source []byte) ([]string,
}
// parseLinks extracts outbound links from the note.
func parseLinks(root ast.Node, source []byte) ([]note.Link, error) {
links := make([]note.Link, 0)
func parseLinks(root ast.Node, source []byte) ([]core.Link, error) {
links := make([]core.Link, 0)
err := ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
@ -218,11 +218,11 @@ func parseLinks(root ast.Node, source []byte) ([]note.Link, error) {
href := string(link.Destination)
if href != "" {
snippet, snStart, snEnd := extractLines(n, source)
links = append(links, note.Link{
links = append(links, core.Link{
Title: string(link.Text(source)),
Href: href,
Rels: strings.Fields(string(link.Title)),
External: strutil.IsURL(href),
Rels: core.LinkRels(strings.Fields(string(link.Title))...),
IsExternal: strutil.IsURL(href),
Snippet: snippet,
SnippetStart: snStart,
SnippetEnd: snEnd,
@ -232,11 +232,11 @@ func parseLinks(root ast.Node, source []byte) ([]note.Link, error) {
case *ast.AutoLink:
if href := string(link.URL(source)); href != "" && link.AutoLinkType == ast.AutoLinkURL {
snippet, snStart, snEnd := extractLines(n, source)
links = append(links, note.Link{
links = append(links, core.Link{
Title: string(link.Label(source)),
Href: href,
Rels: []string{},
External: true,
Rels: []core.LinkRelation{},
IsExternal: true,
Snippet: snippet,
SnippetStart: snStart,
SnippetEnd: snEnd,

@ -3,9 +3,9 @@ package markdown
import (
"testing"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestParseTitle(t *testing.T) {
@ -346,13 +346,13 @@ Tags: [tag1, "#tag1", tag2]
}
func TestParseLinks(t *testing.T) {
test := func(source string, links []note.Link) {
test := func(source string, links []core.Link) {
content := parse(t, source)
assert.Equal(t, content.Links, links)
}
test("", []note.Link{})
test("No links around here", []note.Link{})
test("", []core.Link{})
test("No links around here", []core.Link{})
test(`
# Heading with a [link](heading)
@ -375,51 +375,51 @@ A #[[leading hash]] is used for #uplinks.
Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]
[External links](http://example.com) are marked [as such](ftp://domain).
`, []note.Link{
`, []core.Link{
{
Title: "link",
Href: "heading",
Rels: []string{},
External: false,
Rels: []core.LinkRelation{},
IsExternal: false,
Snippet: "Heading with a [link](heading)",
SnippetStart: 3,
SnippetEnd: 33,
},
{
Title: "multiple links",
Href: "stripped-formatting",
Rels: []string{},
External: false,
Title: "multiple links",
Href: "stripped-formatting",
Rels: []core.LinkRelation{},
IsExternal: false,
Snippet: `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").`,
SnippetStart: 35,
SnippetEnd: 222,
},
{
Title: "relative",
Href: "../other",
Rels: []string{},
External: false,
Title: "relative",
Href: "../other",
Rels: []core.LinkRelation{},
IsExternal: false,
Snippet: `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").`,
SnippetStart: 35,
SnippetEnd: 222,
},
{
Title: "one relation",
Href: "one",
Rels: []string{"rel-1"},
External: false,
Title: "one relation",
Href: "one",
Rels: core.LinkRels("rel-1"),
IsExternal: false,
Snippet: `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").`,
SnippetStart: 35,
SnippetEnd: 222,
},
{
Title: "several relations",
Href: "several",
Rels: []string{"rel-1", "rel-2"},
External: false,
Title: "several relations",
Href: "several",
Rels: core.LinkRels("rel-1", "rel-2"),
IsExternal: false,
Snippet: `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").`,
SnippetStart: 35,
@ -428,8 +428,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "https://inline-link.com",
Href: "https://inline-link.com",
External: true,
Rels: []string{},
IsExternal: true,
Rels: []core.LinkRelation{},
Snippet: "An https://inline-link.com and http://another-inline-link.com.",
SnippetStart: 224,
SnippetEnd: 286,
@ -437,8 +437,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "http://another-inline-link.com",
Href: "http://another-inline-link.com",
External: true,
Rels: []string{},
IsExternal: true,
Rels: []core.LinkRelation{},
Snippet: "An https://inline-link.com and http://another-inline-link.com.",
SnippetStart: 224,
SnippetEnd: 286,
@ -446,8 +446,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Wiki link",
Href: "Wiki link",
External: false,
Rels: []string{},
IsExternal: false,
Rels: []core.LinkRelation{},
Snippet: "A [[Wiki link]] is surrounded by [[2-brackets | two brackets]].",
SnippetStart: 288,
SnippetEnd: 351,
@ -455,8 +455,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "two brackets",
Href: "2-brackets",
External: false,
Rels: []string{},
IsExternal: false,
Rels: []core.LinkRelation{},
Snippet: "A [[Wiki link]] is surrounded by [[2-brackets | two brackets]].",
SnippetStart: 288,
SnippetEnd: 351,
@ -464,8 +464,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: `esca]]ped [chara\cters`,
Href: `esca]]ped [chara\cters`,
External: false,
Rels: []string{},
IsExternal: false,
Rels: []core.LinkRelation{},
Snippet: `It can contain [[esca]\]ped \[chara\\cters]].`,
SnippetStart: 353,
SnippetEnd: 398,
@ -473,8 +473,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Folgezettel link",
Href: "Folgezettel link",
External: false,
Rels: []string{"down"},
IsExternal: false,
Rels: core.LinkRels("down"),
Snippet: "A [[[Folgezettel link]]] is surrounded by three brackets.",
SnippetStart: 400,
SnippetEnd: 457,
@ -482,8 +482,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "trailing hash",
Href: "trailing hash",
External: false,
Rels: []string{"down"},
IsExternal: false,
Rels: core.LinkRels("down"),
Snippet: "Neuron also supports a [[trailing hash]]# for Folgezettel links.",
SnippetStart: 459,
SnippetEnd: 523,
@ -491,8 +491,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "leading hash",
Href: "leading hash",
External: false,
Rels: []string{"up"},
IsExternal: false,
Rels: core.LinkRels("up"),
Snippet: "A #[[leading hash]] is used for #uplinks.",
SnippetStart: 525,
SnippetEnd: 566,
@ -500,8 +500,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Trailing link",
Href: "trailing",
External: false,
Rels: []string{"down"},
IsExternal: false,
Rels: core.LinkRels("down"),
Snippet: "Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]",
SnippetStart: 568,
SnippetEnd: 650,
@ -509,8 +509,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Leading link",
Href: "leading",
External: false,
Rels: []string{"up"},
IsExternal: false,
Rels: core.LinkRels("up"),
Snippet: "Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]",
SnippetStart: 568,
SnippetEnd: 650,
@ -518,8 +518,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "External links",
Href: "http://example.com",
Rels: []string{},
External: true,
Rels: []core.LinkRelation{},
IsExternal: true,
Snippet: `[External links](http://example.com) are marked [as such](ftp://domain).`,
SnippetStart: 652,
SnippetEnd: 724,
@ -527,8 +527,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "as such",
Href: "ftp://domain",
Rels: []string{},
External: true,
Rels: []core.LinkRelation{},
IsExternal: true,
Snippet: `[External links](http://example.com) are marked [as such](ftp://domain).`,
SnippetStart: 652,
SnippetEnd: 724,
@ -564,7 +564,7 @@ Paragraph
})
}
func parse(t *testing.T, source string) note.Content {
func parse(t *testing.T, source string) core.ParsedNote {
return parseWithOptions(t, source, ParserOpts{
HashtagEnabled: true,
MultiWordTagEnabled: true,
@ -572,7 +572,7 @@ func parse(t *testing.T, source string) note.Content {
})
}
func parseWithOptions(t *testing.T, source string, options ParserOpts) note.Content {
func parseWithOptions(t *testing.T, source string, options ParserOpts) core.ParsedNote {
content, err := NewParser(options).Parse(source)
assert.Nil(t, err)
return *content

@ -4,10 +4,9 @@ import (
"database/sql"
"fmt"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
)
// CollectionDAO persists collections (e.g. tags) in the SQLite database.
@ -47,7 +46,7 @@ func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO {
findAllCollectionsStmt: tx.PrepareLazy(`
SELECT c.name, COUNT(nc.id) as count
FROM collections c
LEFT JOIN notes_collections nc ON nc.collection_id = c.id
INNER JOIN notes_collections nc ON nc.collection_id = c.id
WHERE kind = ?
GROUP BY c.id
ORDER BY c.name
@ -75,7 +74,7 @@ func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO {
// FindOrCreate returns the ID of the collection with given kind and name.
// Creates the collection if it does not already exist.
func (d *CollectionDAO) FindOrCreate(kind note.CollectionKind, name string) (core.CollectionId, error) {
func (d *CollectionDAO) FindOrCreate(kind core.CollectionKind, name string) (core.CollectionID, error) {
id, err := d.findCollection(kind, name)
switch {
@ -88,14 +87,14 @@ func (d *CollectionDAO) FindOrCreate(kind note.CollectionKind, name string) (cor
}
}
func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, error) {
func (d *CollectionDAO) FindAll(kind core.CollectionKind) ([]core.Collection, error) {
rows, err := d.findAllCollectionsStmt.Query(kind)
if err != nil {
return []note.Collection{}, err
return []core.Collection{}, err
}
defer rows.Close()
collections := []note.Collection{}
collections := []core.Collection{}
for rows.Next() {
var name string
@ -105,7 +104,7 @@ func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, er
return collections, err
}
collections = append(collections, note.Collection{
collections = append(collections, core.Collection{
Kind: kind,
Name: name,
NoteCount: count,
@ -115,12 +114,12 @@ func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, er
return collections, nil
}
func (d *CollectionDAO) findCollection(kind note.CollectionKind, name string) (core.CollectionId, error) {
func (d *CollectionDAO) findCollection(kind core.CollectionKind, name string) (core.CollectionID, error) {
wrap := errors.Wrapperf("failed to get %s named %s", kind, name)
row, err := d.findCollectionStmt.QueryRow(kind, name)
if err != nil {
return core.CollectionId(0), wrap(err)
return 0, wrap(err)
}
var id sql.NullInt64
@ -128,15 +127,15 @@ func (d *CollectionDAO) findCollection(kind note.CollectionKind, name string) (c
switch {
case err == sql.ErrNoRows:
return core.CollectionId(0), nil
return 0, nil
case err != nil:
return core.CollectionId(0), wrap(err)
return 0, wrap(err)
default:
return core.CollectionId(id.Int64), nil
return core.CollectionID(id.Int64), nil
}
}
func (d *CollectionDAO) create(kind note.CollectionKind, name string) (core.CollectionId, error) {
func (d *CollectionDAO) create(kind core.CollectionKind, name string) (core.CollectionID, error) {
wrap := errors.Wrapperf("failed to create new %s named %s", kind, name)
res, err := d.createCollectionStmt.Exec(kind, name)
@ -149,12 +148,12 @@ func (d *CollectionDAO) create(kind note.CollectionKind, name string) (core.Coll
return 0, wrap(err)
}
return core.CollectionId(id), nil
return core.CollectionID(id), nil
}
// Associate creates a new association between a note and a collection, if it
// does not already exist.
func (d *CollectionDAO) Associate(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) {
func (d *CollectionDAO) Associate(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) {
wrap := errors.Wrapperf("failed to associate note %d to collection %d", noteId, collectionId)
id, err := d.findAssociation(noteId, collectionId)
@ -170,7 +169,7 @@ func (d *CollectionDAO) Associate(noteId core.NoteId, collectionId core.Collecti
}
}
func (d *CollectionDAO) findAssociation(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) {
func (d *CollectionDAO) findAssociation(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) {
if !noteId.IsValid() || !collectionId.IsValid() {
return 0, fmt.Errorf("Note ID (%d) or collection ID (%d) not valid", noteId, collectionId)
}
@ -189,11 +188,11 @@ func (d *CollectionDAO) findAssociation(noteId core.NoteId, collectionId core.Co
case err != nil:
return 0, err
default:
return core.NoteCollectionId(id.Int64), nil
return core.NoteCollectionID(id.Int64), nil
}
}
func (d *CollectionDAO) createAssociation(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) {
func (d *CollectionDAO) createAssociation(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) {
if !noteId.IsValid() || !collectionId.IsValid() {
return 0, fmt.Errorf("Note ID (%d) or collection ID (%d) not valid", noteId, collectionId)
}
@ -208,11 +207,11 @@ func (d *CollectionDAO) createAssociation(noteId core.NoteId, collectionId core.
return 0, err
}
return core.NoteCollectionId(id), nil
return core.NoteCollectionID(id), nil
}
// RemoveAssociations deletes all associations with the given note.
func (d *CollectionDAO) RemoveAssociations(noteId core.NoteId) error {
func (d *CollectionDAO) RemoveAssociations(noteId core.NoteID) error {
if !noteId.IsValid() {
return fmt.Errorf("Note ID (%d) not valid", noteId)
}

@ -3,10 +3,9 @@ package sqlite
import (
"testing"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestCollectionDAOFindOrCreate(t *testing.T) {
@ -14,22 +13,22 @@ func TestCollectionDAOFindOrCreate(t *testing.T) {
// Finds existing ones
id, err := dao.FindOrCreate("tag", "adventure")
assert.Nil(t, err)
assert.Equal(t, id, core.CollectionId(2))
assert.Equal(t, id, core.CollectionID(2))
id, err = dao.FindOrCreate("genre", "fiction")
assert.Nil(t, err)
assert.Equal(t, id, core.CollectionId(3))
assert.Equal(t, id, core.CollectionID(3))
// The name is case sensitive
id, err = dao.FindOrCreate("tag", "Adventure")
assert.Nil(t, err)
assert.NotEqual(t, id, core.CollectionId(2))
assert.NotEqual(t, id, core.CollectionID(2))
// Creates when not found
sql := "SELECT id FROM collections WHERE kind = ? AND name = ?"
assertNotExist(t, tx, sql, "unknown", "created")
assertNotExistTx(t, tx, sql, "unknown", "created")
_, err = dao.FindOrCreate("unknown", "created")
assert.Nil(t, err)
assertExist(t, tx, sql, "unknown", "created")
assertExistTx(t, tx, sql, "unknown", "created")
})
}
@ -43,7 +42,7 @@ func TestCollectionDaoFindAll(t *testing.T) {
// Finds existing
cs, err = dao.FindAll("tag")
assert.Nil(t, err)
assert.Equal(t, cs, []note.Collection{
assert.Equal(t, cs, []core.Collection{
{Kind: "tag", Name: "adventure", NoteCount: 2},
{Kind: "tag", Name: "fantasy", NoteCount: 1},
{Kind: "tag", Name: "fiction", NoteCount: 1},
@ -55,32 +54,32 @@ func TestCollectionDaoFindAll(t *testing.T) {
func TestCollectionDAOAssociate(t *testing.T) {
testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) {
// Returns existing association
id, err := dao.Associate(1, 2)
id, err := dao.Associate(core.NoteID(1), core.CollectionID(2))
assert.Nil(t, err)
assert.Equal(t, id, core.NoteCollectionId(2))
assert.Equal(t, id, core.NoteCollectionID(2))
// Creates a new association if missing
noteId := core.NoteId(5)
collectionId := core.CollectionId(3)
noteId := core.NoteID(5)
collectionId := core.CollectionID(3)
sql := "SELECT id FROM notes_collections WHERE note_id = ? AND collection_id = ?"
assertNotExist(t, tx, sql, noteId, collectionId)
assertNotExistTx(t, tx, sql, noteId, collectionId)
_, err = dao.Associate(noteId, collectionId)
assert.Nil(t, err)
assertExist(t, tx, sql, noteId, collectionId)
assertExistTx(t, tx, sql, noteId, collectionId)
})
}
func TestCollectionDAORemoveAssociations(t *testing.T) {
testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) {
noteId := core.NoteId(1)
noteId := core.NoteID(1)
sql := "SELECT id FROM notes_collections WHERE note_id = ?"
assertExist(t, tx, sql, noteId)
assertExistTx(t, tx, sql, noteId)
err := dao.RemoveAssociations(noteId)
assert.Nil(t, err)
assertNotExist(t, tx, sql, noteId)
assertNotExistTx(t, tx, sql, noteId)
// Removes associations of note without any.
err = dao.RemoveAssociations(999)
err = dao.RemoveAssociations(core.NoteID(999))
assert.Nil(t, err)
})
}

@ -4,8 +4,8 @@ import (
"database/sql"
sqlite "github.com/mattn/go-sqlite3"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/errors"
)
func init() {
@ -38,19 +38,26 @@ func OpenInMemory() (*DB, error) {
func open(uri string) (*DB, error) {
wrap := errors.Wrapper("failed to open the database")
db, err := sql.Open("sqlite3_custom", uri)
nativeDB, err := sql.Open("sqlite3_custom", uri)
if err != nil {
return nil, wrap(err)
}
// Make sure that CASCADE statements are properly applied by enabling
// foreign keys.
_, err = db.Exec("PRAGMA foreign_keys = ON")
_, err = nativeDB.Exec("PRAGMA foreign_keys = ON")
if err != nil {
return nil, wrap(err)
}
return &DB{db}, nil
db := &DB{nativeDB}
err = db.migrate()
if err != nil {
return nil, errors.Wrap(err, "failed to migrate the database")
}
return db, nil
}
// Close terminates the connections to the SQLite database.
@ -59,15 +66,17 @@ func (db *DB) Close() error {
return errors.Wrap(err, "failed to close the database")
}
// Migrate upgrades the SQL schema of the database.
func (db *DB) Migrate() (needsReindexing bool, err error) {
err = db.WithTransaction(func(tx Transaction) error {
// migrate upgrades the SQL schema of the database.
func (db *DB) migrate() error {
err := db.WithTransaction(func(tx Transaction) error {
var version int
err := tx.QueryRow("PRAGMA user_version").Scan(&version)
if err != nil {
return err
}
needsReindexing := false
if version <= 0 {
err = tx.ExecStmts([]string{
// Notes
@ -123,7 +132,6 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
END`,
`PRAGMA user_version = 1`,
})
if err != nil {
return err
}
@ -155,12 +163,11 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
SELECT n.*, GROUP_CONCAT(c.name, '` + "\x01" + `') AS tags
FROM notes n
LEFT JOIN notes_collections nc ON nc.note_id = n.id
LEFT JOIN collections c ON nc.collection_id = c.id AND c.kind = '` + string(note.CollectionKindTag) + `'
LEFT JOIN collections c ON nc.collection_id = c.id AND c.kind = '` + string(core.CollectionKindTag) + `'
GROUP BY n.id`,
`PRAGMA user_version = 2`,
})
if err != nil {
return err
}
@ -177,7 +184,6 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
`PRAGMA user_version = 3`,
})
if err != nil {
return err
}
@ -185,9 +191,30 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
needsReindexing = true
}
if version <= 3 {
err = tx.ExecStmts([]string{
// Metadata
`CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NO NULL
)`,
})
if err != nil {
return err
}
}
if needsReindexing {
metadata := NewMetadataDAO(tx)
// During the next indexing, all notes will be reindexed.
err = metadata.Set(reindexingRequiredKey, "true")
if err != nil {
return err
}
}
return nil
})
err = errors.Wrap(err, "database migration failed")
return
return errors.Wrap(err, "database migration failed")
}

@ -3,8 +3,8 @@ package sqlite
import (
"testing"
"github.com/mickael-menu/zk/util/fixtures"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/util/fixtures"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestOpen(t *testing.T) {
@ -23,12 +23,6 @@ func TestMigrateFrom0(t *testing.T) {
db, err := OpenInMemory()
assert.Nil(t, err)
_, err = db.Migrate()
assert.Nil(t, err)
// Should be able to migrate twice in a row
_, err = db.Migrate()
assert.Nil(t, err)
err = db.WithTransaction(func(tx Transaction) error {
var version int
err := tx.QueryRow("PRAGMA user_version").Scan(&version)

@ -0,0 +1,62 @@
package sqlite
import (
"database/sql"
"github.com/mickael-menu/zk/internal/util/errors"
)
// Known metadata keys.
var reindexingRequiredKey = "zk.reindexing_required"
// MetadataDAO persists arbitrary key/value pairs in the SQLite database.
type MetadataDAO struct {
tx Transaction
// Prepared SQL statements
getStmt *LazyStmt
setStmt *LazyStmt
}
// NewMetadataDAO creates a new instance of a DAO working on the given
// database transaction.
func NewMetadataDAO(tx Transaction) *MetadataDAO {
return &MetadataDAO{
tx: tx,
getStmt: tx.PrepareLazy(`
SELECT key, value FROM metadata WHERE key = ?
`),
setStmt: tx.PrepareLazy(`
INSERT OR REPLACE INTO metadata(key, value)
VALUES (?, ?)
`),
}
}
// Get returns the value for the given key.
func (d *MetadataDAO) Get(key string) (string, error) {
wrap := errors.Wrapperf("failed to get metadata with key %s", key)
row, err := d.getStmt.QueryRow(key)
if err != nil {
return "", wrap(err)
}
var value string
err = row.Scan(&key, &value)
switch {
case err == sql.ErrNoRows:
return "", nil
case err != nil:
return "", wrap(err)
default:
return value, nil
}
}
// Set resets the value for the given metadata key.
func (d *MetadataDAO) Set(key string, value string) error {
_, err := d.setStmt.Exec(key, value)
return err
}

@ -0,0 +1,55 @@
package sqlite
import (
"testing"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestMetadataDAOGetUnknown(t *testing.T) {
testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) {
res, err := dao.Get("unknown")
assert.Nil(t, err)
assert.Equal(t, res, "")
})
}
func TestMetadataDAOGetExisting(t *testing.T) {
testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) {
res, err := dao.Get("a_metadata")
assert.Nil(t, err)
assert.Equal(t, res, "value")
})
}
func TestMetadataDAOSetUnknown(t *testing.T) {
testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) {
res, err := dao.Get("new_metadata")
assert.Nil(t, err)
assert.Equal(t, res, "")
err = dao.Set("new_metadata", "pamplemousse")
assert.Nil(t, err)
res, err = dao.Get("new_metadata")
assert.Nil(t, err)
assert.Equal(t, res, "pamplemousse")
})
}
func TestMetadataDAOSetExisting(t *testing.T) {
testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) {
err := dao.Set("a_metadata", "new_value")
assert.Nil(t, err)
res, err := dao.Get("a_metadata")
assert.Nil(t, err)
assert.Equal(t, res, "new_value")
})
}
func testMetadataDAO(t *testing.T, callback func(tx Transaction, dao *MetadataDAO)) {
testTransaction(t, func(tx Transaction) {
callback(tx, NewMetadataDAO(tx))
})
}

@ -9,19 +9,17 @@ import (
"strings"
"time"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/fts5"
"github.com/mickael-menu/zk/util/icu"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/paths"
strutil "github.com/mickael-menu/zk/util/strings"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/fts5"
"github.com/mickael-menu/zk/internal/util/icu"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/paths"
strutil "github.com/mickael-menu/zk/internal/util/strings"
)
// NoteDAO persists notes in the SQLite database.
// It implements the core port note.Finder.
type NoteDAO struct {
tx Transaction
logger util.Logger
@ -151,7 +149,7 @@ func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) {
}
// Add inserts a new note to the index.
func (d *NoteDAO) Add(note note.Metadata) (core.NoteId, error) {
func (d *NoteDAO) Add(note core.Note) (core.NoteID, error) {
// For sortable_path, we replace in path / by the shortest non printable
// character available to make it sortable. Without this, sorting by the
// path would be a lexicographical sort instead of being the same order
@ -172,16 +170,16 @@ func (d *NoteDAO) Add(note note.Metadata) (core.NoteId, error) {
lastId, err := res.LastInsertId()
if err != nil {
return core.NoteId(0), err
return 0, err
}
id := core.NoteId(lastId)
id := core.NoteID(lastId)
err = d.addLinks(id, note)
return id, err
}
// Update modifies an existing note.
func (d *NoteDAO) Update(note note.Metadata) (core.NoteId, error) {
func (d *NoteDAO) Update(note core.Note) (core.NoteID, error) {
id, err := d.findIdByPath(note.Path)
if err != nil {
return 0, err
@ -208,7 +206,7 @@ func (d *NoteDAO) Update(note note.Metadata) (core.NoteId, error) {
return id, err
}
func (d *NoteDAO) metadataToJSON(note note.Metadata) string {
func (d *NoteDAO) metadataToJSON(note core.Note) string {
json, err := json.Marshal(note.Metadata)
if err != nil {
// Failure to serialize the metadata to JSON should not prevent the
@ -220,14 +218,14 @@ func (d *NoteDAO) metadataToJSON(note note.Metadata) string {
}
// addLinks inserts all the outbound links of the given note.
func (d *NoteDAO) addLinks(id core.NoteId, note note.Metadata) error {
func (d *NoteDAO) addLinks(id core.NoteID, note core.Note) error {
for _, link := range note.Links {
targetId, err := d.findIdByPathPrefix(link.Href)
if err != nil {
return err
}
_, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.External, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd)
_, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.IsExternal, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd)
if err != nil {
return err
}
@ -239,12 +237,16 @@ func (d *NoteDAO) addLinks(id core.NoteId, note note.Metadata) error {
// 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 {
func joinLinkRels(rels []core.LinkRelation) string {
if len(rels) == 0 {
return ""
}
delimiter := "\x01"
return delimiter + strings.Join(rels, delimiter) + delimiter
res := delimiter
for _, rel := range rels {
res += string(rel) + delimiter
}
return res
}
// Remove deletes the note with the given path from the index.
@ -261,16 +263,16 @@ 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
return core.NoteID(0), err
}
return idForRow(row)
}
func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteId, error) {
ids := make([]core.NoteId, 0)
func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteID, error) {
ids := make([]core.NoteID, 0)
for _, path := range paths {
id, err := d.findIdByPathPrefix(path)
if err != nil {
@ -287,71 +289,107 @@ func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteId, error) {
return ids, nil
}
func (d *NoteDAO) findIdByPathPrefix(path string) (core.NoteId, error) {
func (d *NoteDAO) findIdByPathPrefix(path string) (core.NoteID, error) {
row, err := d.findIdByPathPrefixStmt.QueryRow(path)
if err != nil {
return core.NoteId(0), err
return 0, err
}
return idForRow(row)
}
func idForRow(row *sql.Row) (core.NoteId, error) {
func idForRow(row *sql.Row) (core.NoteID, error) {
var id sql.NullInt64
err := row.Scan(&id)
switch {
case err == sql.ErrNoRows:
return core.NoteId(0), nil
return 0, nil
case err != nil:
return core.NoteId(0), err
return 0, err
default:
return core.NoteId(id.Int64), nil
return core.NoteID(id.Int64), nil
}
}
// FindByHref returns the first note matching the given link href.
func (d *NoteDAO) FindByHref(href string) (*note.Match, error) {
id, err := d.findIdByPathPrefix(href)
func (d *NoteDAO) FindMinimal(opts core.NoteFindOpts) ([]core.MinimalNote, error) {
notes := make([]core.MinimalNote, 0)
opts, err := d.expandMentionsIntoMatch(opts)
if err != nil {
return nil, err
return notes, err
}
row, err := d.findByIdStmt.QueryRow(id)
rows, err := d.findRows(opts, true)
if err != nil {
return notes, err
}
defer rows.Close()
for rows.Next() {
note, err := d.scanMinimalNote(rows)
if err != nil {
d.logger.Err(err)
continue
}
if note != nil {
notes = append(notes, *note)
}
}
return notes, nil
}
func (d *NoteDAO) scanMinimalNote(row RowScanner) (*core.MinimalNote, error) {
var (
id int
path, title string
)
err := row.Scan(&id, &path, &title)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
return nil, err
default:
return &core.MinimalNote{
ID: core.NoteID(id),
Path: path,
Title: title,
}, nil
}
return d.scanNoteMatch(row)
}
// Find returns all the notes matching the given criteria.
func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
matches := make([]note.Match, 0)
func (d *NoteDAO) Find(opts core.NoteFindOpts) ([]core.ContextualNote, error) {
notes := make([]core.ContextualNote, 0)
opts, err := d.expandMentionsIntoMatch(opts)
if err != nil {
return matches, err
return notes, err
}
rows, err := d.findRows(opts)
rows, err := d.findRows(opts, false)
if err != nil {
return matches, err
return notes, err
}
defer rows.Close()
for rows.Next() {
match, err := d.scanNoteMatch(rows)
note, err := d.scanNote(rows)
if err != nil {
d.logger.Err(err)
continue
}
if match != nil {
matches = append(matches, *match)
if note != nil {
notes = append(notes, *note)
}
}
return matches, nil
return notes, nil
}
func (d *NoteDAO) scanNoteMatch(row RowScanner) (*note.Match, error) {
func (d *NoteDAO) scanNote(row RowScanner) (*core.ContextualNote, error) {
var (
id, wordCount int
title, lead, body, rawContent string
@ -375,16 +413,17 @@ func (d *NoteDAO) scanNoteMatch(row RowScanner) (*note.Match, error) {
d.logger.Err(errors.Wrap(err, path))
}
return &note.Match{
return &core.ContextualNote{
Snippets: parseListFromNullString(snippets),
Metadata: note.Metadata{
Note: core.Note{
ID: core.NoteID(id),
Path: path,
Title: title,
Lead: lead,
Body: body,
RawContent: rawContent,
WordCount: wordCount,
Links: []note.Link{},
Links: []core.Link{},
Tags: parseListFromNullString(tags),
Metadata: metadata,
Created: created,
@ -407,7 +446,7 @@ func parseListFromNullString(str sql.NullString) []string {
// expandMentionsIntoMatch finds the titles associated with the notes in opts.Mention to
// expand them into the opts.Match predicate.
func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts, error) {
func (d *NoteDAO) expandMentionsIntoMatch(opts core.NoteFindOpts) (core.NoteFindOpts, error) {
if opts.Mention == nil {
return opts, nil
}
@ -419,7 +458,9 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts
}
// Exclude the mentioned notes from the results.
opts = opts.ExcludingIds(ids...)
for _, id := range ids {
opts = opts.ExcludingID(id)
}
// Find their titles.
titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + d.joinIds(ids, ",") + ")"
@ -453,7 +494,7 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts
return opts, nil
}
func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
func (d *NoteDAO) findRows(opts core.NoteFindOpts, minimal bool) (*sql.Rows, error) {
snippetCol := `n.lead`
joinClauses := []string{}
whereExprs := []string{}
@ -600,7 +641,7 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
SELECT note_id FROM notes_collections
WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
)`,
note.CollectionKindTag,
core.CollectionKindTag,
strings.Join(globs, " OR "),
)
whereExprs = append(whereExprs, expr)
@ -614,7 +655,9 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
}
// Exclude the mentioning notes from the results.
opts = opts.ExcludingIds(ids...)
for _, id := range ids {
opts = opts.ExcludingID(id)
}
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)")
@ -673,8 +716,8 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
args = append(args, opts.ModifiedEnd)
}
if opts.ExcludeIds != nil {
whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIds, ",")+")")
if opts.ExcludeIDs != nil {
whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIDs, ",")+")")
}
orderTerms := []string{}
@ -716,9 +759,12 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
query += "\n)\n"
}
query += fmt.Sprintf("SELECT n.id, n.path, n.title, n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.metadata, n.checksum, n.tags, %s AS snippet\n", snippetCol)
query += "SELECT n.id, n.path, n.title"
if !minimal {
query += fmt.Sprintf(", n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.metadata, n.checksum, n.tags, %s AS snippet", snippetCol)
}
query += "FROM notes_with_metadata n\n"
query += "\nFROM notes_with_metadata n\n"
for _, clause := range joinClauses {
query += clause + "\n"
@ -738,32 +784,35 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
query += fmt.Sprintf("LIMIT %d\n", opts.Limit)
}
// fmt.Println(query)
// fmt.Println(args)
// d.logger.Println(query)
// d.logger.Println(args)
return d.tx.Query(query, args...)
}
func orderTerm(sorter note.Sorter) string {
func orderTerm(sorter core.NoteSorter) string {
order := " ASC"
if !sorter.Ascending {
order = " DESC"
}
switch sorter.Field {
case note.SortCreated:
case core.NoteSortCreated:
return "n.created" + order
case note.SortModified:
case core.NoteSortModified:
return "n.modified" + order
case note.SortPath:
case core.NoteSortPath:
return "n.path" + order
case note.SortRandom:
case core.NoteSortRandom:
return "RANDOM()"
case note.SortTitle:
case core.NoteSortTitle:
return "n.title" + order
case note.SortWordCount:
case core.NoteSortWordCount:
return "n.word_count" + order
case core.NoteSortPathLength:
return "LENGTH(path)" + order
default:
panic(fmt.Sprintf("%v: unknown note.SortField", sorter.Field))
panic(fmt.Sprintf("%v: unknown core.NoteSortField", sorter.Field))
}
}
@ -774,7 +823,7 @@ func pathRegex(path string) string {
return path + "[^/]*|" + path + "/.+"
}
func (d *NoteDAO) idToSql(id core.NoteId) sql.NullInt64 {
func (d *NoteDAO) idToSql(id core.NoteID) sql.NullInt64 {
if id.IsValid() {
return sql.NullInt64{Int64: int64(id), Valid: true}
} else {
@ -782,7 +831,7 @@ func (d *NoteDAO) idToSql(id core.NoteId) sql.NullInt64 {
}
}
func (d *NoteDAO) joinIds(ids []core.NoteId, delimiter string) string {
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))

@ -6,17 +6,16 @@ import (
"testing"
"time"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/util"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/paths"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/paths"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestNoteDAOIndexed(t *testing.T) {
testNoteDAOWithFixtures(t, "", func(tx Transaction, dao *NoteDAO) {
for _, note := range []note.Metadata{
for _, note := range []core.Note{
{
Path: "a.md",
Modified: time.Date(2020, 1, 20, 8, 52, 42, 0, time.UTC),
@ -105,7 +104,7 @@ func TestNoteDAOIndexed(t *testing.T) {
func TestNoteDAOAdd(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
_, err := dao.Add(note.Metadata{
_, err := dao.Add(core.Note{
Path: "log/added.md",
Title: "Added note",
Lead: "Note",
@ -138,13 +137,13 @@ func TestNoteDAOAdd(t *testing.T) {
func TestNoteDAOAddWithLinks(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
id, err := dao.Add(note.Metadata{
id, err := dao.Add(core.Note{
Path: "log/added.md",
Links: []note.Link{
Links: []core.Link{
{
Title: "Same dir",
Href: "log/2021-01-04",
Rels: []string{"rel-1", "rel-2"},
Rels: core.LinkRels("rel-1", "rel-2"),
},
{
Title: "Relative",
@ -156,17 +155,17 @@ func TestNoteDAOAddWithLinks(t *testing.T) {
{
Title: "Second is added",
Href: "f39c8",
Rels: []string{"second"},
Rels: core.LinkRels("second"),
},
{
Title: "Unknown",
Href: "unknown",
},
{
Title: "URL",
Href: "http://example.com",
External: true,
Snippet: "External [URL](http://example.com)",
Title: "URL",
Href: "http://example.com",
IsExternal: true,
Snippet: "External [URL](http://example.com)",
},
},
})
@ -206,13 +205,13 @@ func TestNoteDAOAddWithLinks(t *testing.T) {
Rels: "",
},
{
SourceId: id,
TargetId: nil,
Title: "URL",
Href: "http://example.com",
External: true,
Rels: "",
Snippet: "External [URL](http://example.com)",
SourceId: id,
TargetId: nil,
Title: "URL",
Href: "http://example.com",
IsExternal: true,
Rels: "",
Snippet: "External [URL](http://example.com)",
},
})
})
@ -220,7 +219,7 @@ func TestNoteDAOAddWithLinks(t *testing.T) {
func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
id, err := dao.Add(note.Metadata{
id, err := dao.Add(core.Note{
Path: "missing_target.md",
})
assert.Nil(t, err)
@ -241,14 +240,14 @@ func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) {
// 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) {
_, err := dao.Add(note.Metadata{Path: "ref/test/a.md"})
_, err := dao.Add(core.Note{Path: "ref/test/a.md"})
assert.Err(t, err, "UNIQUE constraint failed: notes.path")
})
}
func TestNoteDAOUpdate(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
id, err := dao.Update(note.Metadata{
id, err := dao.Update(core.Note{
Path: "ref/test/a.md",
Title: "Updated note",
Lead: "Updated lead",
@ -261,7 +260,7 @@ func TestNoteDAOUpdate(t *testing.T) {
Modified: time.Date(2020, 11, 22, 16, 49, 47, 0, time.UTC),
})
assert.Nil(t, err)
assert.Equal(t, id, core.NoteId(6))
assert.Equal(t, id, core.NoteID(6))
row, err := queryNoteRow(tx, `path = "ref/test/a.md"`)
assert.Nil(t, err)
@ -282,7 +281,7 @@ func TestNoteDAOUpdate(t *testing.T) {
func TestNoteDAOUpdateUnknown(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
_, err := dao.Update(note.Metadata{
_, err := dao.Update(core.Note{
Path: "unknown/unknown.md",
})
assert.Err(t, err, "note not found in the index")
@ -301,30 +300,30 @@ func TestNoteDAOUpdateWithLinks(t *testing.T) {
Snippet: "[[An internal link]]",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
External: true,
Snippet: "[[An external link]]",
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
IsExternal: true,
Snippet: "[[An external link]]",
},
})
_, err := dao.Update(note.Metadata{
_, err := dao.Update(core.Note{
Path: "log/2021-01-03.md",
Links: []note.Link{
Links: []core.Link{
{
Title: "A new link",
Href: "index",
External: false,
Rels: []string{"rel"},
Snippet: "[[A new link]]",
Title: "A new link",
Href: "index",
IsExternal: false,
Rels: core.LinkRels("rel"),
Snippet: "[[A new link]]",
},
{
Title: "An external link",
Href: "https://domain.com",
External: true,
Snippet: "[[An external link]]",
Title: "An external link",
Href: "https://domain.com",
IsExternal: true,
Snippet: "[[An external link]]",
},
},
})
@ -341,12 +340,12 @@ func TestNoteDAOUpdateWithLinks(t *testing.T) {
Snippet: "[[A new link]]",
},
{
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
External: true,
Snippet: "[[An external link]]",
SourceId: 1,
TargetId: nil,
Title: "An external link",
Href: "https://domain.com",
IsExternal: true,
Snippet: "[[An external link]]",
},
})
})
@ -379,7 +378,7 @@ func TestNoteDAORemoveCascadeLinks(t *testing.T) {
assert.Equal(t, len(links) > 0, true)
links = queryLinkRows(t, tx, `id = 4`)
assert.Equal(t, *links[0].TargetId, core.NoteId(1))
assert.Equal(t, *links[0].TargetId, core.NoteID(1))
err := dao.Remove("log/2021-01-03.md")
assert.Nil(t, err)
@ -392,76 +391,49 @@ func TestNoteDAORemoveCascadeLinks(t *testing.T) {
})
}
func TestNoteDAOFindByHref(t *testing.T) {
test := func(href string, expected *note.Match) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
actual, err := dao.FindByHref(href)
assert.Nil(t, err)
assert.Equal(t, actual, expected)
})
}
test("not-found", nil)
// Full path
test("log/2021-01-03.md", &note.Match{
Metadata: note.Metadata{
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
Links: []note.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
},
Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Checksum: "qwfpgj",
},
Snippets: []string{"A daily note"},
})
func TestNoteDAOFindMinimalAll(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
notes, err := dao.FindMinimal(core.NoteFindOpts{})
assert.Nil(t, err)
// Prefix
test("log/2021-01", &note.Match{
Metadata: note.Metadata{
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
Links: []note.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
},
Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
Checksum: "qwfpgj",
},
Snippets: []string{"A daily note"},
assert.Equal(t, notes, []core.MinimalNote{
{ID: 5, Path: "ref/test/b.md", Title: "A nested note"},
{ID: 4, Path: "f39c8.md", Title: "An interesting note"},
{ID: 6, Path: "ref/test/a.md", Title: "Another nested note"},
{ID: 1, Path: "log/2021-01-03.md", Title: "Daily note"},
{ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021"},
{ID: 3, Path: "index.md", Title: "Index"},
{ID: 2, Path: "log/2021-01-04.md", Title: "January 4, 2021"},
})
})
}
// Prefix matches the shortest path (fixes https://github.com/mickael-menu/zk/issues/23)
testNoteDAOWithFixtures(t, "issue23", func(tx Transaction, dao *NoteDAO) {
actual, err := dao.FindByHref("prefix")
func TestNoteDAOFindMinimalWithFilter(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
notes, err := dao.FindMinimal(core.NoteFindOpts{
Match: opt.NewString("daily | index"),
Sorters: []core.NoteSorter{{Field: core.NoteSortWordCount, Ascending: true}},
Limit: 3,
})
assert.Nil(t, err)
assert.Equal(t, actual.Path, "prefix-short.md")
assert.Equal(t, notes, []core.MinimalNote{
{ID: 1, Path: "log/2021-01-03.md", Title: "Daily note"},
{ID: 3, Path: "index.md", Title: "Index"},
{ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021"},
})
})
}
func TestNoteDAOFindAll(t *testing.T) {
testNoteDAOFindPaths(t, note.FinderOpts{}, []string{
testNoteDAOFindPaths(t, core.NoteFindOpts{}, []string{
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md",
"log/2021-02-04.md", "index.md", "log/2021-01-04.md",
})
}
func TestNoteDAOFindLimit(t *testing.T) {
testNoteDAOFindPaths(t, note.FinderOpts{Limit: 2}, []string{
testNoteDAOFindPaths(t, core.NoteFindOpts{Limit: 2}, []string{
"ref/test/b.md",
"f39c8.md",
})
@ -469,7 +441,7 @@ func TestNoteDAOFindLimit(t *testing.T) {
func TestNoteDAOFindTag(t *testing.T) {
test := func(tags []string, expectedPaths []string) {
testNoteDAOFindPaths(t, note.FinderOpts{Tags: tags}, expectedPaths)
testNoteDAOFindPaths(t, core.NoteFindOpts{Tags: tags}, expectedPaths)
}
test([]string{"fiction"}, []string{"log/2021-01-03.md"})
@ -488,17 +460,18 @@ func TestNoteDAOFindTag(t *testing.T) {
func TestNoteDAOFindMatch(t *testing.T) {
testNoteDAOFind(t,
note.FinderOpts{Match: opt.NewString("daily | index")},
[]note.Match{
core.NoteFindOpts{Match: opt.NewString("daily | index")},
[]core.ContextualNote{
{
Metadata: note.Metadata{
Note: core.Note{
ID: 3,
Path: "index.md",
Title: "Index",
Lead: "Index of the Zettelkasten",
Body: "Index of the Zettelkasten",
RawContent: "# Index\nIndex of the Zettelkasten",
WordCount: 4,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{
"aliases": []interface{}{"First page"},
@ -510,14 +483,15 @@ func TestNoteDAOFindMatch(t *testing.T) {
Snippets: []string{"<zk:match>Index</zk:match> of the Zettelkasten"},
},
{
Metadata: note.Metadata{
Note: core.Note{
ID: 1,
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
@ -529,14 +503,15 @@ func TestNoteDAOFindMatch(t *testing.T) {
Snippets: []string{"A <zk:match>daily</zk:match> note\n\nWith lot of content"},
},
{
Metadata: note.Metadata{
Note: core.Note{
ID: 7,
Path: "log/2021-02-04.md",
Title: "February 4, 2021",
Lead: "A third daily note",
Body: "A third daily note",
RawContent: "# A third daily note",
WordCount: 4,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@ -546,14 +521,15 @@ func TestNoteDAOFindMatch(t *testing.T) {
Snippets: []string{"A third <zk:match>daily</zk:match> note"},
},
{
Metadata: note.Metadata{
Note: core.Note{
ID: 2,
Path: "log/2021-01-04.md",
Title: "January 4, 2021",
Lead: "A second daily note",
Body: "A second daily note",
RawContent: "# A second daily note",
WordCount: 4,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@ -568,10 +544,10 @@ func TestNoteDAOFindMatch(t *testing.T) {
func TestNoteDAOFindMatchWithSort(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
Match: opt.NewString("daily | index"),
Sorters: []note.Sorter{
{Field: note.SortPath, Ascending: false},
Sorters: []core.NoteSorter{
{Field: core.NoteSortPath, Ascending: false},
},
},
[]string{
@ -585,7 +561,7 @@ func TestNoteDAOFindMatchWithSort(t *testing.T) {
func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
IncludePaths: []string{"log/2021-01-03.md"},
},
[]string{"log/2021-01-03.md"},
@ -595,7 +571,7 @@ func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) {
// You can look for files with only their prefix.
func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
IncludePaths: []string{"log/2021-01"},
},
[]string{"log/2021-01-03.md", "log/2021-01-04.md"},
@ -605,13 +581,13 @@ func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) {
// For directory, only complete names work, no prefixes.
func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
IncludePaths: []string{"lo"},
},
[]string{},
)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
IncludePaths: []string{"log"},
},
[]string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"},
@ -621,7 +597,7 @@ func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) {
// You can look for multiple paths, in which case notes can be in any of them.
func TestNoteDAOFindInMultiplePaths(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
IncludePaths: []string{"ref", "index.md"},
},
[]string{"ref/test/b.md", "ref/test/a.md", "index.md"},
@ -630,7 +606,7 @@ func TestNoteDAOFindInMultiplePaths(t *testing.T) {
func TestNoteDAOFindExcludingPath(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
ExcludePaths: []string{"log"},
},
[]string{"ref/test/b.md", "f39c8.md", "ref/test/a.md", "index.md"},
@ -639,7 +615,7 @@ func TestNoteDAOFindExcludingPath(t *testing.T) {
func TestNoteDAOFindExcludingMultiplePaths(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
ExcludePaths: []string{"ref", "log/2021-01"},
},
[]string{"f39c8.md", "log/2021-02-04.md", "index.md"},
@ -648,17 +624,18 @@ func TestNoteDAOFindExcludingMultiplePaths(t *testing.T) {
func TestNoteDAOFindMentions(t *testing.T) {
testNoteDAOFind(t,
note.FinderOpts{Mention: []string{"log/2021-01-03.md", "index.md"}},
[]note.Match{
core.NoteFindOpts{Mention: []string{"log/2021-01-03.md", "index.md"}},
[]core.ContextualNote{
{
Metadata: note.Metadata{
Note: core.Note{
ID: 5,
Path: "ref/test/b.md",
Title: "A nested note",
Lead: "This one is in a sub sub directory",
Body: "This one is in a sub sub directory, not the first page",
RawContent: "# A nested note\nThis one is in a sub sub directory",
WordCount: 8,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{"adventure", "history"},
Metadata: map[string]interface{}{},
Created: time.Date(2019, 11, 20, 20, 32, 56, 0, time.UTC),
@ -668,14 +645,15 @@ func TestNoteDAOFindMentions(t *testing.T) {
Snippets: []string{"This one is in a sub sub directory, not the <zk:match>first page</zk:match>"},
},
{
Metadata: note.Metadata{
Note: core.Note{
ID: 7,
Path: "log/2021-02-04.md",
Title: "February 4, 2021",
Lead: "A third daily note",
Body: "A third daily note",
RawContent: "# A third daily note",
WordCount: 4,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@ -685,14 +663,15 @@ func TestNoteDAOFindMentions(t *testing.T) {
Snippets: []string{"A third <zk:match>daily note</zk:match>"},
},
{
Metadata: note.Metadata{
Note: core.Note{
ID: 2,
Path: "log/2021-01-04.md",
Title: "January 4, 2021",
Lead: "A second daily note",
Body: "A second daily note",
RawContent: "# A second daily note",
WordCount: 4,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@ -708,9 +687,9 @@ func TestNoteDAOFindMentions(t *testing.T) {
// Common use case: `--mention x --no-link-to x`
func TestNoteDAOFindUnlinkedMentions(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
Mention: []string{"log/2021-01-03.md", "index.md"},
LinkTo: &note.LinkFilter{
LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-03.md", "index.md"},
Negate: true,
},
@ -721,17 +700,18 @@ func TestNoteDAOFindUnlinkedMentions(t *testing.T) {
func TestNoteDAOFindMentionedBy(t *testing.T) {
testNoteDAOFind(t,
note.FinderOpts{MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"}},
[]note.Match{
core.NoteFindOpts{MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"}},
[]core.ContextualNote{
{
Metadata: note.Metadata{
Note: core.Note{
ID: 1,
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
@ -743,14 +723,15 @@ func TestNoteDAOFindMentionedBy(t *testing.T) {
Snippets: []string{"A second <zk:match>daily note</zk:match>"},
},
{
Metadata: note.Metadata{
Note: core.Note{
ID: 3,
Path: "index.md",
Title: "Index",
Lead: "Index of the Zettelkasten",
Body: "Index of the Zettelkasten",
RawContent: "# Index\nIndex of the Zettelkasten",
WordCount: 4,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{
"aliases": []interface{}{
@ -770,9 +751,9 @@ func TestNoteDAOFindMentionedBy(t *testing.T) {
// Common use case: `--mentioned-by x --no-linked-by x`
func TestNoteDAOFindUnlinkedMentionedBy(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"},
LinkedBy: &note.LinkFilter{
LinkedBy: &core.LinkFilter{
Paths: []string{"ref/test/b.md", "log/2021-01-04.md"},
Negate: true,
},
@ -783,8 +764,8 @@ func TestNoteDAOFindUnlinkedMentionedBy(t *testing.T) {
func TestNoteDAOFindLinkedBy(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkedBy: &note.LinkFilter{
core.NoteFindOpts{
LinkedBy: &core.LinkFilter{
Paths: []string{"f39c8.md", "log/2021-01-03"},
Negate: false,
Recursive: false,
@ -796,8 +777,8 @@ func TestNoteDAOFindLinkedBy(t *testing.T) {
func TestNoteDAOFindLinkedByRecursive(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkedBy: &note.LinkFilter{
core.NoteFindOpts{
LinkedBy: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@ -809,8 +790,8 @@ func TestNoteDAOFindLinkedByRecursive(t *testing.T) {
func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkedBy: &note.LinkFilter{
core.NoteFindOpts{
LinkedBy: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@ -823,19 +804,20 @@ func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) {
func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
testNoteDAOFind(t,
note.FinderOpts{
LinkedBy: &note.LinkFilter{Paths: []string{"f39c8.md"}},
core.NoteFindOpts{
LinkedBy: &core.LinkFilter{Paths: []string{"f39c8.md"}},
},
[]note.Match{
[]core.ContextualNote{
{
Metadata: note.Metadata{
Note: core.Note{
ID: 6,
Path: "ref/test/a.md",
Title: "Another nested note",
Lead: "It shall appear before b.md",
Body: "It shall appear before b.md",
RawContent: "#Another nested note\nIt shall appear before b.md",
WordCount: 5,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{
"alias": "a.md",
@ -850,14 +832,15 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
},
},
{
Metadata: note.Metadata{
Note: core.Note{
ID: 1,
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
Links: []note.Link{},
Links: []core.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
@ -876,8 +859,8 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
func TestNoteDAOFindNotLinkedBy(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkedBy: &note.LinkFilter{
core.NoteFindOpts{
LinkedBy: &core.LinkFilter{
Paths: []string{"f39c8.md", "log/2021-01-03"},
Negate: true,
Recursive: false,
@ -889,8 +872,8 @@ func TestNoteDAOFindNotLinkedBy(t *testing.T) {
func TestNoteDAOFindLinkTo(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkTo: &note.LinkFilter{
core.NoteFindOpts{
LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-04", "ref/test/a.md"},
Negate: false,
Recursive: false,
@ -902,8 +885,8 @@ func TestNoteDAOFindLinkTo(t *testing.T) {
func TestNoteDAOFindLinkToRecursive(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkTo: &note.LinkFilter{
core.NoteFindOpts{
LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@ -915,8 +898,8 @@ func TestNoteDAOFindLinkToRecursive(t *testing.T) {
func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkTo: &note.LinkFilter{
core.NoteFindOpts{
LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@ -929,8 +912,8 @@ func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) {
func TestNoteDAOFindNotLinkTo(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
LinkTo: &note.LinkFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true},
core.NoteFindOpts{
LinkTo: &core.LinkFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true},
},
[]string{"ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md"},
)
@ -938,14 +921,14 @@ func TestNoteDAOFindNotLinkTo(t *testing.T) {
func TestNoteDAOFindRelated(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
Related: []string{"log/2021-02-04"},
},
[]string{},
)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
Related: []string{"log/2021-01-03.md"},
},
[]string{"index.md"},
@ -954,7 +937,7 @@ func TestNoteDAOFindRelated(t *testing.T) {
func TestNoteDAOFindOrphan(t *testing.T) {
testNoteDAOFindPaths(t,
note.FinderOpts{Orphan: true},
core.NoteFindOpts{Orphan: true},
[]string{"ref/test/b.md", "log/2021-02-04.md"},
)
}
@ -963,7 +946,7 @@ func TestNoteDAOFindCreatedOn(t *testing.T) {
start := time.Date(2020, 11, 22, 0, 0, 0, 0, time.UTC)
end := time.Date(2020, 11, 23, 0, 0, 0, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
CreatedStart: &start,
CreatedEnd: &end,
},
@ -974,7 +957,7 @@ func TestNoteDAOFindCreatedOn(t *testing.T) {
func TestNoteDAOFindCreatedBefore(t *testing.T) {
end := time.Date(2019, 12, 04, 11, 59, 11, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
CreatedEnd: &end,
},
[]string{"ref/test/b.md", "ref/test/a.md"},
@ -984,7 +967,7 @@ func TestNoteDAOFindCreatedBefore(t *testing.T) {
func TestNoteDAOFindCreatedAfter(t *testing.T) {
start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
CreatedStart: &start,
},
[]string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"},
@ -995,7 +978,7 @@ func TestNoteDAOFindModifiedOn(t *testing.T) {
start := time.Date(2020, 01, 20, 0, 0, 0, 0, time.UTC)
end := time.Date(2020, 01, 21, 0, 0, 0, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
ModifiedStart: &start,
ModifiedEnd: &end,
},
@ -1006,7 +989,7 @@ func TestNoteDAOFindModifiedOn(t *testing.T) {
func TestNoteDAOFindModifiedBefore(t *testing.T) {
end := time.Date(2020, 01, 20, 8, 52, 42, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
ModifiedEnd: &end,
},
[]string{"ref/test/b.md", "ref/test/a.md", "index.md"},
@ -1016,7 +999,7 @@ func TestNoteDAOFindModifiedBefore(t *testing.T) {
func TestNoteDAOFindModifiedAfter(t *testing.T) {
start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC)
testNoteDAOFindPaths(t,
note.FinderOpts{
core.NoteFindOpts{
ModifiedStart: &start,
},
[]string{"log/2021-01-03.md", "log/2021-01-04.md"},
@ -1024,70 +1007,70 @@ func TestNoteDAOFindModifiedAfter(t *testing.T) {
}
func TestNoteDAOFindSortCreated(t *testing.T) {
testNoteDAOFindSort(t, note.SortCreated, true, []string{
testNoteDAOFindSort(t, core.NoteSortCreated, true, []string{
"ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md",
"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md",
})
testNoteDAOFindSort(t, note.SortCreated, false, []string{
testNoteDAOFindSort(t, core.NoteSortCreated, false, []string{
"log/2021-02-04.md", "log/2021-01-04.md", "log/2021-01-03.md",
"f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md",
})
}
func TestNoteDAOFindSortModified(t *testing.T) {
testNoteDAOFindSort(t, note.SortModified, true, []string{
testNoteDAOFindSort(t, core.NoteSortModified, true, []string{
"ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md",
"log/2021-02-04.md", "log/2021-01-03.md", "log/2021-01-04.md",
})
testNoteDAOFindSort(t, note.SortModified, false, []string{
testNoteDAOFindSort(t, core.NoteSortModified, false, []string{
"log/2021-01-04.md", "log/2021-01-03.md", "log/2021-02-04.md",
"f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md",
})
}
func TestNoteDAOFindSortPath(t *testing.T) {
testNoteDAOFindSort(t, note.SortPath, true, []string{
testNoteDAOFindSort(t, core.NoteSortPath, true, []string{
"f39c8.md", "index.md", "log/2021-01-03.md", "log/2021-01-04.md",
"log/2021-02-04.md", "ref/test/a.md", "ref/test/b.md",
})
testNoteDAOFindSort(t, note.SortPath, false, []string{
testNoteDAOFindSort(t, core.NoteSortPath, false, []string{
"ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md",
"log/2021-01-04.md", "log/2021-01-03.md", "index.md", "f39c8.md",
})
}
func TestNoteDAOFindSortTitle(t *testing.T) {
testNoteDAOFindSort(t, note.SortTitle, true, []string{
testNoteDAOFindSort(t, core.NoteSortTitle, true, []string{
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md",
"log/2021-02-04.md", "index.md", "log/2021-01-04.md",
})
testNoteDAOFindSort(t, note.SortTitle, false, []string{
testNoteDAOFindSort(t, core.NoteSortTitle, false, []string{
"log/2021-01-04.md", "index.md", "log/2021-02-04.md",
"log/2021-01-03.md", "ref/test/a.md", "f39c8.md", "ref/test/b.md",
})
}
func TestNoteDAOFindSortWordCount(t *testing.T) {
testNoteDAOFindSort(t, note.SortWordCount, true, []string{
testNoteDAOFindSort(t, core.NoteSortWordCount, true, []string{
"log/2021-01-03.md", "log/2021-02-04.md", "index.md",
"log/2021-01-04.md", "f39c8.md", "ref/test/a.md", "ref/test/b.md",
})
testNoteDAOFindSort(t, note.SortWordCount, false, []string{
testNoteDAOFindSort(t, core.NoteSortWordCount, false, []string{
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md",
"index.md", "log/2021-01-04.md", "log/2021-01-03.md",
})
}
func testNoteDAOFindSort(t *testing.T, field note.SortField, ascending bool, expected []string) {
func testNoteDAOFindSort(t *testing.T, field core.NoteSortField, ascending bool, expected []string) {
testNoteDAOFindPaths(t,
note.FinderOpts{
Sorters: []note.Sorter{{Field: field, Ascending: ascending}},
core.NoteFindOpts{
Sorters: []core.NoteSorter{{Field: field, Ascending: ascending}},
},
expected,
)
}
func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string) {
func testNoteDAOFindPaths(t *testing.T, opts core.NoteFindOpts, expected []string) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
matches, err := dao.Find(opts)
assert.Nil(t, err)
@ -1100,7 +1083,7 @@ func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string)
})
}
func testNoteDAOFind(t *testing.T, opts note.FinderOpts, expected []note.Match) {
func testNoteDAOFind(t *testing.T, opts core.NoteFindOpts, expected []core.ContextualNote) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
actual, err := dao.Find(opts)
assert.Nil(t, err)
@ -1137,11 +1120,11 @@ func queryNoteRow(tx Transaction, where string) (noteRow, error) {
}
type linkRow struct {
SourceId core.NoteId
TargetId *core.NoteId
SourceId core.NoteID
TargetId *core.NoteID
Href, Title, Rels, Snippet string
SnippetStart, SnippetEnd int
External bool
IsExternal bool
}
func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow {
@ -1159,9 +1142,9 @@ func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow {
var row linkRow
var sourceId int64
var targetId *int64
err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.External, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd)
err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.IsExternal, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd)
assert.Nil(t, err)
row.SourceId = core.NoteId(sourceId)
row.SourceId = core.NoteID(sourceId)
if targetId != nil {
row.TargetId = idPointer(*targetId)
}
@ -1173,7 +1156,7 @@ func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow {
return links
}
func idPointer(i int64) *core.NoteId {
id := core.NoteId(i)
func idPointer(i int64) *core.NoteID {
id := core.NoteID(i)
return &id
}

@ -0,0 +1,171 @@
package sqlite
import (
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/paths"
)
// NoteIndex persists note indexing results in the SQLite database.
// It implements the port core.NoteIndex and acts as a facade to the DAOs.
type NoteIndex struct {
db *DB
dao *dao
logger util.Logger
}
type dao struct {
notes *NoteDAO
collections *CollectionDAO
metadata *MetadataDAO
}
func NewNoteIndex(db *DB, logger util.Logger) *NoteIndex {
return &NoteIndex{
db: db,
logger: logger,
}
}
// Find implements core.NoteIndex.
func (ni *NoteIndex) Find(opts core.NoteFindOpts) (notes []core.ContextualNote, err error) {
err = ni.commit(func(dao *dao) error {
notes, err = dao.notes.Find(opts)
return err
})
return
}
// FindMinimal implements core.NoteIndex.
func (ni *NoteIndex) FindMinimal(opts core.NoteFindOpts) (notes []core.MinimalNote, err error) {
err = ni.commit(func(dao *dao) error {
notes, err = dao.notes.FindMinimal(opts)
return err
})
return
}
// FindCollections implements core.NoteIndex.
func (ni *NoteIndex) FindCollections(kind core.CollectionKind) (collections []core.Collection, err error) {
err = ni.commit(func(dao *dao) error {
collections, err = dao.collections.FindAll(kind)
return err
})
return
}
// IndexedPaths implements core.NoteIndex.
func (ni *NoteIndex) IndexedPaths() (metadata <-chan paths.Metadata, err error) {
err = ni.commit(func(dao *dao) error {
metadata, err = dao.notes.Indexed()
return err
})
err = errors.Wrap(err, "failed to get indexed notes")
return
}
// Add implements core.NoteIndex.
func (ni *NoteIndex) Add(note core.Note) (id core.NoteID, err error) {
err = ni.commit(func(dao *dao) error {
id, err = dao.notes.Add(note)
if err != nil {
return err
}
return ni.associateTags(dao.collections, id, note.Tags)
})
err = errors.Wrapf(err, "%v: failed to index the note", note.Path)
return
}
// 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)
if err != nil {
return err
}
err = dao.collections.RemoveAssociations(noteId)
if err != nil {
return err
}
return ni.associateTags(dao.collections, noteId, note.Tags)
})
return errors.Wrapf(err, "%v: failed to update note index", note.Path)
}
func (ni *NoteIndex) associateTags(collections *CollectionDAO, noteId core.NoteID, tags []string) error {
for _, tag := range tags {
tagId, err := collections.FindOrCreate(core.CollectionKindTag, tag)
if err != nil {
return err
}
_, err = collections.Associate(noteId, tagId)
if err != nil {
return err
}
}
return nil
}
// Remove implements core.NoteIndex
func (ni *NoteIndex) Remove(path string) error {
err := ni.commit(func(dao *dao) error {
return dao.notes.Remove(path)
})
return errors.Wrapf(err, "%v: failed to remove note from index", path)
}
// Commit implements core.NoteIndex.
func (ni *NoteIndex) Commit(transaction func(idx core.NoteIndex) error) error {
return ni.commit(func(dao *dao) error {
return transaction(&NoteIndex{
db: ni.db,
dao: dao,
logger: ni.logger,
})
})
}
// NeedsReindexing implements core.NoteIndex.
func (ni *NoteIndex) NeedsReindexing() (needsReindexing bool, err error) {
err = ni.commit(func(dao *dao) error {
res, err := dao.metadata.Get(reindexingRequiredKey)
needsReindexing = (res == "true")
return err
})
return
}
// SetNeedsReindexing implements core.NoteIndex.
func (ni *NoteIndex) SetNeedsReindexing(needsReindexing bool) error {
return ni.commit(func(dao *dao) error {
value := "false"
if needsReindexing {
value = "true"
}
return dao.metadata.Set(reindexingRequiredKey, value)
})
}
func (ni *NoteIndex) commit(transaction func(dao *dao) error) error {
if ni.dao != nil {
return transaction(ni.dao)
} else {
return ni.db.WithTransaction(func(tx Transaction) error {
dao := dao{
notes: NewNoteDAO(tx, ni.logger),
collections: NewCollectionDAO(tx, ni.logger),
metadata: NewMetadataDAO(tx),
}
return transaction(&dao)
})
}
}

@ -0,0 +1,62 @@
package sqlite
import (
"testing"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
// FIXME: Missing tests
func TestNoteIndexAddWithTags(t *testing.T) {
db, index := testNoteIndex(t)
assertSQL := func(after bool) {
assertTagExistsOrNot(t, db, true, "fiction")
assertTagExistsOrNot(t, db, after, "new-tag")
}
assertSQL(false)
id, err := index.Add(core.Note{
Path: "log/added.md",
Tags: []string{"new-tag", "fiction"},
})
assert.Nil(t, err)
assertSQL(true)
assertTaggedOrNot(t, db, true, id, "new-tag")
assertTaggedOrNot(t, db, true, id, "fiction")
}
func TestNoteIndexUpdateWithTags(t *testing.T) {
db, index := testNoteIndex(t)
id := core.NoteID(1)
assertSQL := func(after bool) {
assertTaggedOrNot(t, db, true, id, "fiction")
assertTaggedOrNot(t, db, after, id, "new-tag")
assertTaggedOrNot(t, db, after, id, "fantasy")
}
assertSQL(false)
err := index.Update(core.Note{
Path: "log/2021-01-03.md",
Tags: []string{"new-tag", "fiction", "fantasy"},
})
assert.Nil(t, err)
assertSQL(true)
}
func testNoteIndex(t *testing.T) (*DB, *NoteIndex) {
db := testDB(t)
return db, NewNoteIndex(db, &util.NullLogger)
}
func assertTagExistsOrNot(t *testing.T, db *DB, shouldExist bool, tag string) {
assertExistOrNot(t, db, shouldExist, "SELECT id FROM collections WHERE kind = 'tag' AND name = ?", tag)
}
func assertTaggedOrNot(t *testing.T, db *DB, shouldBeTagged bool, noteId core.NoteID, tag string) {
assertExistOrNot(t, db, shouldBeTagged, "SELECT id FROM notes_collections WHERE note_id = ? AND collection_id IS (SELECT id FROM collections WHERE kind = 'tag' AND name = ?)", noteId, tag)
}

@ -4,7 +4,7 @@ import (
"database/sql"
"sync"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/internal/util/errors"
)
// LazyStmt is a wrapper around a sql.Stmt which will be evaluated on first use.

@ -13,3 +13,6 @@
- id: 5
kind: "tag"
name: "history"
- id: 6
kind: "tag"
name: "empty"

@ -0,0 +1,2 @@
- key: "a_metadata"
value: "value"

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b7ba9b3ab5296c19e6a7a61ee4ca43116840ef956a1491c670bd7591abbd313
size 86016

@ -0,0 +1,106 @@
package sqlite
import (
"testing"
"github.com/go-testfixtures/testfixtures/v3"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
// testDB is an utility function to create a database loaded with the default fixtures.
func testDB(t *testing.T) *DB {
return testDBWithFixtures(t, opt.NewString("default"))
}
// testDB is an utility function to create a database loaded with a set of DB fixtures.
func testDBWithFixtures(t *testing.T, fixturesDir opt.String) *DB {
db, err := OpenInMemory()
assert.Nil(t, err)
if !fixturesDir.IsNull() {
fixtures, err := testfixtures.New(
testfixtures.Database(db.db),
testfixtures.Dialect("sqlite"),
testfixtures.Directory("testdata/"+fixturesDir.String()),
// Necessary to work with an in-memory database.
testfixtures.DangerousSkipTestDatabaseCheck(),
)
assert.Nil(t, err)
err = fixtures.Load()
assert.Nil(t, err)
}
return db
}
// testTransaction is an utility function used to test a SQLite transaction to
// the DB, which loads the default set of DB fixtures.
func testTransaction(t *testing.T, test func(tx Transaction)) {
testTransactionWithFixtures(t, opt.NewString("default"), test)
}
// testTransactionWithFixtures is an utility function used to test a SQLite transaction to
// the DB, which loads the given set of DB fixtures.
func testTransactionWithFixtures(t *testing.T, fixturesDir opt.String, test func(tx Transaction)) {
err := testDBWithFixtures(t, fixturesDir).WithTransaction(func(tx Transaction) error {
test(tx)
return nil
})
assert.Nil(t, err)
}
func assertExistOrNot(t *testing.T, db *DB, shouldExist bool, sql string, args ...interface{}) {
if shouldExist {
assertExist(t, db, sql, args...)
} else {
assertNotExist(t, db, sql, args...)
}
}
func assertExist(t *testing.T, db *DB, sql string, args ...interface{}) {
if !exists(t, db, sql, args...) {
t.Errorf("SQL query did not return any result: %s, with arguments %v", sql, args)
}
}
func assertNotExist(t *testing.T, db *DB, sql string, args ...interface{}) {
if exists(t, db, sql, args...) {
t.Errorf("SQL query returned a result: %s, with arguments %v", sql, args)
}
}
func exists(t *testing.T, db *DB, sql string, args ...interface{}) bool {
var exists int
err := db.db.QueryRow("SELECT EXISTS ("+sql+")", args...).Scan(&exists)
assert.Nil(t, err)
return exists == 1
}
// FIXME: Migrate to DB-based versions?
func assertExistOrNotTx(t *testing.T, tx Transaction, shouldExist bool, sql string, args ...interface{}) {
if shouldExist {
assertExistTx(t, tx, sql, args...)
} else {
assertNotExistTx(t, tx, sql, args...)
}
}
func assertExistTx(t *testing.T, tx Transaction, sql string, args ...interface{}) {
if !existsTx(t, tx, sql, args...) {
t.Errorf("SQL query did not return any result: %s, with arguments %v", sql, args)
}
}
func assertNotExistTx(t *testing.T, tx Transaction, sql string, args ...interface{}) {
if existsTx(t, tx, sql, args...) {
t.Errorf("SQL query returned a result: %s, with arguments %v", sql, args)
}
}
func existsTx(t *testing.T, tx Transaction, sql string, args ...interface{}) bool {
var exists int
err := tx.QueryRow("SELECT EXISTS ("+sql+")", args...).Scan(&exists)
assert.Nil(t, err)
return exists == 1
}

@ -0,0 +1,120 @@
package term
import (
"fmt"
"github.com/fatih/color"
"github.com/mickael-menu/zk/internal/core"
)
// Style implements core.Styler using ANSI escape codes to be used with a terminal.
func (t *Terminal) Style(text string, rules ...core.Style) (string, error) {
if text == "" {
return text, nil
}
attrs, err := attributes(expandThemeAliases(rules))
if err != nil {
return "", err
}
if len(attrs) == 0 {
return text, nil
}
return color.New(attrs...).Sprint(text), nil
}
func (t *Terminal) MustStyle(text string, rules ...core.Style) string {
text, err := t.Style(text, rules...)
if err != nil {
panic(err.Error())
}
return text
}
// FIXME: User config
var themeAliases = map[core.Style][]core.Style{
"title": {"bold", "yellow"},
"path": {"underline", "cyan"},
"term": {"red"},
"emphasis": {"bold", "cyan"},
"understate": {"faint"},
}
func expandThemeAliases(rules []core.Style) []core.Style {
expanded := make([]core.Style, 0)
for _, rule := range rules {
aliases, ok := themeAliases[rule]
if ok {
aliases = expandThemeAliases(aliases)
for _, alias := range aliases {
expanded = append(expanded, alias)
}
} else {
expanded = append(expanded, rule)
}
}
return expanded
}
var attrsMapping = map[core.Style]color.Attribute{
core.StyleBold: color.Bold,
core.StyleFaint: color.Faint,
core.StyleItalic: color.Italic,
core.StyleUnderline: color.Underline,
core.StyleBlink: color.BlinkSlow,
core.StyleReverse: color.ReverseVideo,
core.StyleHidden: color.Concealed,
core.StyleStrikethrough: color.CrossedOut,
core.StyleBlack: color.FgBlack,
core.StyleRed: color.FgRed,
core.StyleGreen: color.FgGreen,
core.StyleYellow: color.FgYellow,
core.StyleBlue: color.FgBlue,
core.StyleMagenta: color.FgMagenta,
core.StyleCyan: color.FgCyan,
core.StyleWhite: color.FgWhite,
core.StyleBlackBg: color.BgBlack,
core.StyleRedBg: color.BgRed,
core.StyleGreenBg: color.BgGreen,
core.StyleYellowBg: color.BgYellow,
core.StyleBlueBg: color.BgBlue,
core.StyleMagentaBg: color.BgMagenta,
core.StyleCyanBg: color.BgCyan,
core.StyleWhiteBg: color.BgWhite,
core.StyleBrightBlack: color.FgHiBlack,
core.StyleBrightRed: color.FgHiRed,
core.StyleBrightGreen: color.FgHiGreen,
core.StyleBrightYellow: color.FgHiYellow,
core.StyleBrightBlue: color.FgHiBlue,
core.StyleBrightMagenta: color.FgHiMagenta,
core.StyleBrightCyan: color.FgHiCyan,
core.StyleBrightWhite: color.FgHiWhite,
core.StyleBrightBlackBg: color.BgHiBlack,
core.StyleBrightRedBg: color.BgHiRed,
core.StyleBrightGreenBg: color.BgHiGreen,
core.StyleBrightYellowBg: color.BgHiYellow,
core.StyleBrightBlueBg: color.BgHiBlue,
core.StyleBrightMagentaBg: color.BgHiMagenta,
core.StyleBrightCyanBg: color.BgHiCyan,
core.StyleBrightWhiteBg: color.BgHiWhite,
}
func attributes(rules []core.Style) ([]color.Attribute, error) {
attrs := make([]color.Attribute, 0)
for _, rule := range rules {
attr, ok := attrsMapping[rule]
if !ok {
return attrs, fmt.Errorf("unknown styling rule: %v", rule)
} else {
attrs = append(attrs, attr)
}
}
return attrs, nil
}

@ -4,8 +4,8 @@ import (
"testing"
"github.com/fatih/color"
"github.com/mickael-menu/zk/core/style"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func createTerminal() *Terminal {
@ -20,24 +20,24 @@ func TestStyleNoRule(t *testing.T) {
}
func TestStyleOneRule(t *testing.T) {
res, err := createTerminal().Style("Hello", style.Rule("red"))
res, err := createTerminal().Style("Hello", core.Style("red"))
assert.Nil(t, err)
assert.Equal(t, res, "\033[31mHello\033[0m")
}
func TestStyleMultipleRule(t *testing.T) {
res, err := createTerminal().Style("Hello", style.Rule("red"), style.Rule("bold"))
res, err := createTerminal().Style("Hello", core.Style("red"), core.Style("bold"))
assert.Nil(t, err)
assert.Equal(t, res, "\033[31;1mHello\033[0m")
}
func TestStyleUnknownRule(t *testing.T) {
_, err := createTerminal().Style("Hello", style.Rule("unknown"))
_, err := createTerminal().Style("Hello", core.Style("unknown"))
assert.Err(t, err, "unknown styling rule: unknown")
}
func TestStyleEmptyString(t *testing.T) {
res, err := createTerminal().Style("", style.Rule("bold"))
res, err := createTerminal().Style("", core.Style("bold"))
assert.Nil(t, err)
assert.Equal(t, res, "")
}
@ -45,7 +45,7 @@ func TestStyleEmptyString(t *testing.T) {
func TestStyleAllRules(t *testing.T) {
styler := createTerminal()
test := func(rule string, expected string) {
res, err := styler.Style("Hello", style.Rule(rule))
res, err := styler.Style("Hello", core.Style(rule))
assert.Nil(t, err)
assert.Equal(t, res, "\033["+expected+"Hello\033[0m")
}

@ -4,6 +4,7 @@ import (
"os"
"strings"
survey "github.com/AlecAivazis/survey/v2"
"github.com/mattn/go-isatty"
)
@ -33,3 +34,18 @@ func (t *Terminal) SupportsUTF8() bool {
lc := strings.ToUpper(os.Getenv("LC_ALL"))
return strings.Contains(lang, "UTF") || strings.Contains(lc, "UTF")
}
// Confirm is a shortcut to prompt a yes/no question to the user.
func (t *Terminal) Confirm(msg string, defaultAnswer bool) (confirmed, skipped bool) {
if !t.IsInteractive() {
return defaultAnswer, true
}
confirmed = false
prompt := &survey.Confirm{
Message: msg,
Default: defaultAnswer,
}
survey.AskOne(prompt, &confirmed)
return confirmed, false
}

@ -4,50 +4,46 @@ import (
"fmt"
"path/filepath"
"github.com/mickael-menu/zk/adapter"
"github.com/mickael-menu/zk/adapter/fzf"
"github.com/mickael-menu/zk/adapter/sqlite"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/errors"
"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"
)
// Edit opens notes matching a set of criteria with the user editor.
type Edit struct {
Force bool `short:f help:"Do not confirm before editing many notes at the same time."`
Filtering
cli.Filtering
}
func (cmd *Edit) Run(container *adapter.Container) error {
zk, err := container.Zk()
func (cmd *Edit) Run(container *cli.Container) error {
notebook, err := container.CurrentNotebook()
if err != nil {
return err
}
opts, err := NewFinderOpts(zk, cmd.Filtering)
findOpts, err := cmd.Filtering.NewNoteFindOpts(notebook)
if err != nil {
return errors.Wrapf(err, "incorrect criteria")
}
db, _, err := container.Database(false)
notes, err := notebook.FindNotes(findOpts)
if err != nil {
return err
}
var notes []note.Match
err = db.WithTransaction(func(tx sqlite.Transaction) error {
finder := container.NoteFinder(tx, fzf.NoteFinderOpts{
AlwaysFilter: true,
PreviewCmd: container.Config.Tool.FzfPreview,
NewNoteDir: cmd.newNoteDir(zk),
BasePath: zk.Path,
CurrentPath: container.WorkingDir,
})
notes, err = finder.Find(*opts)
return err
filter := container.NewNoteFilter(fzf.NoteFilterOpts{
Interactive: cmd.Interactive,
AlwaysFilter: true,
PreviewCmd: container.Config.Tool.FzfPreview,
NewNoteDir: cmd.newNoteDir(notebook),
NotebookDir: notebook.Path,
WorkingDir: container.WorkingDir,
})
notes, err = filter.Apply(notes)
if err != nil {
if err == note.ErrCanceled {
if err == fzf.ErrCancelled {
return nil
}
return err
@ -66,32 +62,35 @@ func (cmd *Edit) Run(container *adapter.Container) error {
}
paths := make([]string, 0)
for _, note := range notes {
absPath := filepath.Join(zk.Path, note.Path)
absPath := filepath.Join(notebook.Path, note.Path)
paths = append(paths, absPath)
}
note.Edit(zk, paths...)
editor, err := container.NewNoteEditor(notebook)
if err != nil {
return err
}
return editor.Open(paths...)
} else {
fmt.Println("Found 0 note")
return nil
}
return err
}
// newNoteDir returns the directory in which to create a new note when the fzf
// binding is triggered.
func (cmd *Edit) newNoteDir(zk *zk.Zk) *zk.Dir {
func (cmd *Edit) newNoteDir(notebook *core.Notebook) *core.Dir {
switch len(cmd.Path) {
case 0:
dir := zk.RootDir()
dir := notebook.RootDir()
return &dir
case 1:
dir, err := zk.DirAt(cmd.Path[0])
dir, err := notebook.DirAt(cmd.Path[0])
if err != nil {
return nil
}
return dir
return &dir
default:
// More than one directory, it's ambiguous for the "new note" fzf binding.
return nil

@ -3,7 +3,7 @@ package cmd
import (
"fmt"
"github.com/mickael-menu/zk/adapter"
"github.com/mickael-menu/zk/internal/cli"
)
// Index indexes the content of all the notes in the notebook.
@ -16,15 +16,20 @@ func (cmd *Index) Help() string {
return "You usually do not need to run `zk index` manually, as notes are indexed automatically when needed."
}
func (cmd *Index) Run(container *adapter.Container) error {
_, stats, err := container.Database(cmd.Force)
func (cmd *Index) Run(container *cli.Container) error {
notebook, err := container.CurrentNotebook()
if err != nil {
return err
}
if err == nil && !cmd.Quiet {
stats, err := notebook.Index(cmd.Force)
if err != nil {
return err
}
if !cmd.Quiet {
fmt.Println(stats)
}
return err
return nil
}

@ -0,0 +1,34 @@
package cmd
import (
"fmt"
"path/filepath"
"github.com/mickael-menu/zk/internal/cli"
)
// Init creates a notebook in the given directory
type Init struct {
Directory string `arg optional type:"path" default:"." help:"Directory containing the notebook."`
}
func (cmd *Init) Run(container *cli.Container) error {
notebook, err := container.Notebooks.Init(cmd.Directory)
if err != nil {
return err
}
force := false
_, err = notebook.Index(force)
if err != nil {
return err
}
path, err := filepath.Abs(cmd.Directory)
if err != nil {
path = cmd.Directory
}
fmt.Printf("Initialized a notebook in %v\n", path)
return nil
}

@ -0,0 +1,139 @@
package cmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/mickael-menu/zk/internal/adapter/fzf"
"github.com/mickael-menu/zk/internal/cli"
"github.com/mickael-menu/zk/internal/util/errors"
strutil "github.com/mickael-menu/zk/internal/util/strings"
)
// List displays notes matching a set of criteria.
type List struct {
Format string `group:format short:f placeholder:TEMPLATE help:"Pretty print the list using the given format."`
Delimiter string "group:format short:d default:\n help:\"Print notes delimited by the given separator.\""
Delimiter0 bool "group:format short:0 name:delimiter0 help:\"Print notes delimited by ASCII NUL characters. This is useful when used in conjunction with `xargs -0`.\""
NoPager bool `group:format short:P help:"Do not pipe output into a pager."`
Quiet bool `group:format short:q help:"Do not print the total number of notes found."`
cli.Filtering
}
func (cmd *List) Run(container *cli.Container) error {
if cmd.Delimiter0 {
cmd.Delimiter = "\x00"
}
notebook, err := container.CurrentNotebook()
if err != nil {
return err
}
format, err := notebook.NewNoteFormatter(cmd.noteTemplate())
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
}
filter := container.NewNoteFilter(fzf.NoteFilterOpts{
Interactive: cmd.Interactive,
AlwaysFilter: false,
PreviewCmd: container.Config.Tool.FzfPreview,
NotebookDir: notebook.Path,
WorkingDir: container.WorkingDir,
})
notes, err = filter.Apply(notes)
if err != nil {
if err == fzf.ErrCancelled {
return nil
}
return err
}
count := len(notes)
if count > 0 {
err = container.Paginate(cmd.NoPager, func(out io.Writer) error {
for i, note := range notes {
if i > 0 {
fmt.Fprint(out, cmd.Delimiter)
}
ft, err := format(note)
if err != nil {
return err
}
fmt.Fprint(out, ft)
}
if cmd.Delimiter0 {
fmt.Fprint(out, "\x00")
}
return nil
})
}
if err == nil && !cmd.Quiet {
fmt.Fprintf(os.Stderr, "\n\nFound %d %s\n", count, strutil.Pluralize("note", count))
}
return err
}
func (cmd *List) noteTemplate() string {
format := cmd.Format
if format == "" {
format = "short"
}
templ, ok := defaultNoteFormats[format]
if !ok {
templ = format
// Replace raw \n and \t by actual newlines and tabs in user format.
templ = strings.ReplaceAll(templ, "\\n", "\n")
templ = strings.ReplaceAll(templ, "\\t", "\t")
}
return templ
}
var defaultNoteFormats = map[string]string{
"path": `{{path}}`,
"oneline": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`,
"short": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{list snippets}}`,
"medium": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
{{list snippets}}`,
"long": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
{{list snippets}}`,
"full": `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
Tags: {{join tags ", "}}
{{prepend " " body}}
`,
}

@ -0,0 +1,64 @@
package cmd
import (
"testing"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestListFormatDefault(t *testing.T) {
cmd := List{}
assert.Equal(t, cmd.noteTemplate(), `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{list snippets}}`)
}
func TestListFormatPredefined(t *testing.T) {
test := func(format, expectedTemplate string) {
cmd := List{Format: format}
assert.Equal(t, cmd.noteTemplate(), expectedTemplate)
}
// Known formats
test("path", `{{path}}`)
test("oneline", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`)
test("short", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})
{{list snippets}}`)
test("medium", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
{{list snippets}}`)
test("long", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
{{list snippets}}`)
test("full", `{{style "title" title}} {{style "path" path}}
Created: {{date created "short"}}
Modified: {{date created "short"}}
Tags: {{join tags ", "}}
{{prepend " " body}}
`)
// Predefined formats are case sensitive.
test("Path", "Path")
}
func TestListFormatCustom(t *testing.T) {
test := func(format, expectedTemplate string) {
cmd := List{Format: format}
assert.Equal(t, cmd.noteTemplate(), expectedTemplate)
}
// Custom formats are used literally.
test("{{title}}", "{{title}}")
// \n and \t in custom formats are expanded.
test(`{{title}}\t{{path}}\n{{snippet}}`, "{{title}}\t{{path}}\n{{snippet}}")
}

@ -1,9 +1,9 @@
package cmd
import (
"github.com/mickael-menu/zk/adapter"
"github.com/mickael-menu/zk/adapter/lsp"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/internal/adapter/lsp"
"github.com/mickael-menu/zk/internal/cli"
"github.com/mickael-menu/zk/internal/util/opt"
)
// LSP starts a server implementing the Language Server Protocol.
@ -11,12 +11,14 @@ type LSP struct {
Log string `type:path placeholder:PATH help:"Absolute path to the log file"`
}
func (cmd *LSP) Run(container *adapter.Container) error {
func (cmd *LSP) Run(container *cli.Container) error {
server := lsp.NewServer(lsp.ServerOpts{
Name: "zk",
Version: container.Version,
Logger: container.Logger,
LogFile: opt.NewNotEmptyString(cmd.Log),
Container: container,
Notebooks: container.Notebooks,
FS: container.FS,
})
return server.Run()

@ -0,0 +1,76 @@
package cmd
import (
"errors"
"fmt"
"time"
"github.com/mickael-menu/zk/internal/cli"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/os"
)
// New adds a new note to the notebook.
type New struct {
Directory string `arg optional default:"." help:"Directory in which to create the note."`
Title string `short:t placeholder:TITLE help:"Title of the new note."`
Group string `short:g placeholder:NAME help:"Name of the config group this note belongs to. Takes precedence over the config of the directory."`
Extra map[string]string ` help:"Extra variables passed to the templates." mapsep:","`
Template string ` placeholder:PATH help:"Custom template used to render the note."`
PrintPath bool `short:p help:"Print the path of the created note instead of editing it."`
}
func (cmd *New) Run(container *cli.Container) error {
notebook, err := container.CurrentNotebook()
if err != nil {
return err
}
content, err := os.ReadStdinPipe()
if err != nil {
return err
}
path, err := notebook.NewNote(core.NewNoteOpts{
Title: opt.NewNotEmptyString(cmd.Title),
Content: content.Unwrap(),
Directory: opt.NewNotEmptyString(cmd.Directory),
Group: opt.NewNotEmptyString(cmd.Group),
Template: opt.NewNotEmptyString(cmd.Template),
Extra: cmd.Extra,
Date: time.Now(),
})
if err != nil {
var noteExists core.ErrNoteExists
if !errors.As(err, &noteExists) {
return err
}
relPath, err := notebook.RelPath(path)
if err != nil {
return err
}
if confirmed, _ := container.Terminal.Confirm(
fmt.Sprintf("%s already exists, do you want to edit this note instead?", relPath),
true,
); !confirmed {
// abort...
return nil
}
path = noteExists.Path
}
if cmd.PrintPath {
fmt.Printf("%+v\n", path)
return nil
} else {
editor, err := container.NewNoteEditor(notebook)
if err != nil {
return err
}
return editor.Open(path)
}
}

@ -0,0 +1,190 @@
package cli
import (
"io"
"os"
"path/filepath"
"github.com/mickael-menu/zk/internal/adapter/editor"
"github.com/mickael-menu/zk/internal/adapter/fs"
"github.com/mickael-menu/zk/internal/adapter/fzf"
"github.com/mickael-menu/zk/internal/adapter/handlebars"
"github.com/mickael-menu/zk/internal/adapter/markdown"
"github.com/mickael-menu/zk/internal/adapter/sqlite"
"github.com/mickael-menu/zk/internal/adapter/term"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
osutil "github.com/mickael-menu/zk/internal/util/os"
"github.com/mickael-menu/zk/internal/util/pager"
"github.com/mickael-menu/zk/internal/util/paths"
"github.com/mickael-menu/zk/internal/util/rand"
)
type Container struct {
Version string
Config core.Config
Logger *util.ProxyLogger
Terminal *term.Terminal
FS *fs.FileStorage
WorkingDir string
Notebooks *core.NotebookStore
currentNotebook *core.Notebook
currentNotebookErr error
}
func NewContainer(version string) (*Container, error) {
wrap := errors.Wrapper("initialization")
term := term.New()
styler := term
logger := util.NewProxyLogger(util.NewStdLogger("zk: ", 0))
fs, err := fs.NewFileStorage("", logger)
config := core.NewDefaultConfig()
handlebars.Init(term.SupportsUTF8(), logger)
// Load global user config
configPath, err := locateGlobalConfig()
if err != nil {
return nil, wrap(err)
}
if configPath != "" {
config, err = core.OpenConfig(configPath, config, fs)
if err != nil {
return nil, wrap(err)
}
}
return &Container{
Version: version,
Config: config,
Logger: logger,
Terminal: term,
FS: fs,
Notebooks: core.NewNotebookStore(config, core.NotebookStorePorts{
FS: fs,
NotebookFactory: func(path string, config core.Config) (*core.Notebook, error) {
dbPath := filepath.Join(path, ".zk/notebook.db")
db, err := sqlite.Open(dbPath)
if err != nil {
return nil, err
}
notebook := core.NewNotebook(path, config, core.NotebookPorts{
NoteIndex: sqlite.NewNoteIndex(db, logger),
NoteParser: markdown.NewParser(markdown.ParserOpts{
HashtagEnabled: config.Format.Markdown.Hashtags,
MultiWordTagEnabled: config.Format.Markdown.MultiwordTags,
ColontagEnabled: config.Format.Markdown.ColonTags,
}),
TemplateLoaderFactory: func(language string) (core.TemplateLoader, error) {
return handlebars.NewLoader(handlebars.LoaderOpts{
LookupPaths: []string{
filepath.Join(globalConfigDir(), "templates"),
filepath.Join(path, ".zk/templates"),
},
Lang: config.Note.Lang,
Styler: styler,
Logger: logger,
}), nil
},
IDGeneratorFactory: func(opts core.IDOptions) func() string {
return rand.NewIDGenerator(opts)
},
FS: fs,
Logger: logger,
OSEnv: func() map[string]string {
return osutil.Env()
},
})
return notebook, nil
},
}),
}, nil
}
// locateGlobalConfig looks for the global zk config file following the
// XDG Base Directory specification
// https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
func locateGlobalConfig() (string, error) {
configPath := filepath.Join(globalConfigDir(), "config.toml")
exists, err := paths.Exists(configPath)
switch {
case err != nil:
return "", err
case exists:
return configPath, nil
default:
return "", nil
}
}
// globalConfigDir returns the parent directory of the global configuration file.
func globalConfigDir() string {
path, ok := os.LookupEnv("XDG_CONFIG_HOME")
if !ok {
home, ok := os.LookupEnv("HOME")
if !ok {
home = "~/"
}
path = filepath.Join(home, ".config")
}
return filepath.Join(path, "zk")
}
// SetCurrentNotebook sets the first notebook found in the given search paths
// as the current default one.
func (c *Container) SetCurrentNotebook(searchPaths []string) {
if len(searchPaths) == 0 {
return
}
for _, path := range searchPaths {
c.currentNotebook, c.currentNotebookErr = c.Notebooks.Open(path)
if c.currentNotebookErr == nil {
c.WorkingDir = path
c.FS.WorkingDir = path
c.Config = c.currentNotebook.Config
// FIXME: Is there something to do to support multiple notebooks here?
os.Setenv("ZK_NOTEBOOK_DIR", c.currentNotebook.Path)
return
}
}
}
// CurrentNotebook returns the current default notebook.
func (c *Container) CurrentNotebook() (*core.Notebook, error) {
return c.currentNotebook, c.currentNotebookErr
}
func (c *Container) NewNoteFilter(opts fzf.NoteFilterOpts) *fzf.NoteFilter {
return fzf.NewNoteFilter(opts, c.Terminal)
}
func (c *Container) NewNoteEditor(notebook *core.Notebook) (*editor.Editor, error) {
return editor.NewEditor(notebook.Config.Tool.Editor)
}
// Paginate creates an auto-closing io.Writer which will be automatically
// paginated if noPager is false, using the user's pager.
//
// You can write to the pager only in the run callback.
func (c *Container) Paginate(noPager bool, run func(out io.Writer) error) error {
pager, err := c.pager(noPager || c.Config.Tool.Pager.IsEmpty())
if err != nil {
return err
}
err = run(pager)
pager.Close()
return err
}
func (c *Container) pager(noPager bool) (*pager.Pager, error) {
if noPager || !c.Terminal.IsInteractive() {
return pager.PassthroughPager, nil
} else {
return pager.New(c.Config.Tool.Pager, c.Logger)
}
}

@ -1,4 +1,4 @@
package cmd
package cli
import (
"fmt"
@ -7,11 +7,10 @@ import (
"github.com/alecthomas/kong"
"github.com/kballard/go-shellquote"
"github.com/mickael-menu/zk/core/note"
"github.com/mickael-menu/zk/core/zk"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/strings"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/strings"
"github.com/tj/go-naturaldate"
)
@ -129,136 +128,134 @@ func (f Filtering) ExpandNamedFilters(filters map[string]string, expandedFilters
return f, nil
}
// NewFinderOpts creates an instance of note.FinderOpts from a set of user flags.
func NewFinderOpts(zk *zk.Zk, filtering Filtering) (*note.FinderOpts, error) {
filtering, err := filtering.ExpandNamedFilters(zk.Config.Filters, []string{})
// NewNoteFindOpts creates an instance of core.NoteFindOpts from a set of user flags.
func (f Filtering) NewNoteFindOpts(notebook *core.Notebook) (core.NoteFindOpts, error) {
opts := core.NoteFindOpts{}
f, err := f.ExpandNamedFilters(notebook.Config.Filters, []string{})
if err != nil {
return nil, err
return opts, err
}
opts := note.FinderOpts{}
opts.Match = opt.NewNotEmptyString(filtering.Match)
opts.Match = opt.NewNotEmptyString(f.Match)
if paths, ok := relPaths(zk, filtering.Path); ok {
if paths, ok := relPaths(notebook, f.Path); ok {
opts.IncludePaths = paths
}
if paths, ok := relPaths(zk, filtering.Exclude); ok {
if paths, ok := relPaths(notebook, f.Exclude); ok {
opts.ExcludePaths = paths
}
if len(filtering.Tag) > 0 {
opts.Tags = filtering.Tag
if len(f.Tag) > 0 {
opts.Tags = f.Tag
}
if len(filtering.Mention) > 0 {
opts.Mention = filtering.Mention
if len(f.Mention) > 0 {
opts.Mention = f.Mention
}
if len(filtering.MentionedBy) > 0 {
opts.MentionedBy = filtering.MentionedBy
if len(f.MentionedBy) > 0 {
opts.MentionedBy = f.MentionedBy
}
if paths, ok := relPaths(zk, filtering.LinkedBy); ok {
opts.LinkedBy = &note.LinkFilter{
if paths, ok := relPaths(notebook, f.LinkedBy); ok {
opts.LinkedBy = &core.LinkFilter{
Paths: paths,
Negate: false,
Recursive: filtering.Recursive,
MaxDistance: filtering.MaxDistance,
Recursive: f.Recursive,
MaxDistance: f.MaxDistance,
}
} else if paths, ok := relPaths(zk, filtering.NoLinkedBy); ok {
opts.LinkedBy = &note.LinkFilter{
} else if paths, ok := relPaths(notebook, f.NoLinkedBy); ok {
opts.LinkedBy = &core.LinkFilter{
Paths: paths,
Negate: true,
}
}
if paths, ok := relPaths(zk, filtering.LinkTo); ok {
opts.LinkTo = &note.LinkFilter{
if paths, ok := relPaths(notebook, f.LinkTo); ok {
opts.LinkTo = &core.LinkFilter{
Paths: paths,
Negate: false,
Recursive: filtering.Recursive,
MaxDistance: filtering.MaxDistance,
Recursive: f.Recursive,
MaxDistance: f.MaxDistance,
}
} else if paths, ok := relPaths(zk, filtering.NoLinkTo); ok {
opts.LinkTo = &note.LinkFilter{
} else if paths, ok := relPaths(notebook, f.NoLinkTo); ok {
opts.LinkTo = &core.LinkFilter{
Paths: paths,
Negate: true,
}
}
if paths, ok := relPaths(zk, filtering.Related); ok {
if paths, ok := relPaths(notebook, f.Related); ok {
opts.Related = paths
}
opts.Orphan = filtering.Orphan
opts.Orphan = f.Orphan
if filtering.Created != "" {
start, end, err := parseDayRange(filtering.Created)
if f.Created != "" {
start, end, err := parseDayRange(f.Created)
if err != nil {
return nil, err
return opts, err
}
opts.CreatedStart = &start
opts.CreatedEnd = &end
} else {
if filtering.CreatedBefore != "" {
date, err := parseDate(filtering.CreatedBefore)
if f.CreatedBefore != "" {
date, err := parseDate(f.CreatedBefore)
if err != nil {
return nil, err
return opts, err
}
opts.CreatedEnd = &date
}
if filtering.CreatedAfter != "" {
date, err := parseDate(filtering.CreatedAfter)
if f.CreatedAfter != "" {
date, err := parseDate(f.CreatedAfter)
if err != nil {
return nil, err
return opts, err
}
opts.CreatedStart = &date
}
}
if filtering.Modified != "" {
start, end, err := parseDayRange(filtering.Modified)
if f.Modified != "" {
start, end, err := parseDayRange(f.Modified)
if err != nil {
return nil, err
return opts, err
}
opts.ModifiedStart = &start
opts.ModifiedEnd = &end
} else {
if filtering.ModifiedBefore != "" {
date, err := parseDate(filtering.ModifiedBefore)
if f.ModifiedBefore != "" {
date, err := parseDate(f.ModifiedBefore)
if err != nil {
return nil, err
return opts, err
}
opts.ModifiedEnd = &date
}
if filtering.ModifiedAfter != "" {
date, err := parseDate(filtering.ModifiedAfter)
if f.ModifiedAfter != "" {
date, err := parseDate(f.ModifiedAfter)
if err != nil {
return nil, err
return opts, err
}
opts.ModifiedStart = &date
}
}
opts.Interactive = filtering.Interactive
sorters, err := note.SortersFromStrings(filtering.Sort)
sorters, err := core.NoteSortersFromStrings(f.Sort)
if err != nil {
return nil, err
return opts, err
}
opts.Sorters = sorters
opts.Limit = filtering.Limit
opts.Limit = f.Limit
return &opts, nil
return opts, nil
}
func relPaths(zk *zk.Zk, paths []string) ([]string, bool) {
func relPaths(notebook *core.Notebook, paths []string) ([]string, bool) {
relPaths := make([]string, 0)
for _, p := range paths {
path, err := zk.RelPath(p)
path, err := notebook.RelPath(p)
if err == nil {
relPaths = append(relPaths, path)
}

@ -1,9 +1,9 @@
package cmd
package cli
import (
"testing"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestExpandNamedFiltersNone(t *testing.T) {

@ -0,0 +1,56 @@
package core
// Collection represents a collection, such as a tag.
type Collection struct {
// Unique ID of this collection in the Notebook.
ID CollectionID
// Kind of this note collection, such as a tag.
Kind CollectionKind
// Name of this collection.
Name string
// Number of notes associated with this collection.
NoteCount int
}
// CollectionID represents the unique ID of a collection relative to a given
// NoteIndex implementation.
type CollectionID int64
func (id CollectionID) IsValid() bool {
return id > 0
}
// NoteCollectionID represents the unique ID of an association between a note
// and a collection in a NoteIndex implementation.
type NoteCollectionID int64
func (id NoteCollectionID) IsValid() bool {
return id > 0
}
// CollectionKind defines a kind of note collection, such as tags.
type CollectionKind string
const (
CollectionKindTag CollectionKind = "tag"
)
// CollectionRepository persists note collection across sessions.
type CollectionRepository interface {
// FindOrCreate returns the ID of the collection with given kind and name.
// If the collection does not exist, creates a new one.
FindOrCreateCollection(name string, kind CollectionKind) (CollectionID, error)
// FindCollections returns the list of all collections in the repository
// for the given kind.
FindCollections(kind CollectionKind) ([]Collection, error)
// AssociateNoteCollection creates a new association between a note and a
// collection, if it does not already exist.
AssociateNoteCollection(noteID NoteID, collectionID CollectionID) (NoteCollectionID, error)
// RemoveNoteCollections deletes all collection associations with the given
// note.
RemoveNoteAssociations(noteId NoteID) error
}

@ -1,16 +1,15 @@
package zk
package core
import (
"io/ioutil"
"fmt"
"path/filepath"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/paths"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/opt"
toml "github.com/pelletier/go-toml"
)
// Config holds the global user configuration.
// Config holds the user configuration.
type Config struct {
Note NoteConfig
Groups map[string]GroupConfig
@ -19,8 +18,6 @@ type Config struct {
Filters map[string]string
Aliases map[string]string
Extra map[string]string
// Base directories for the relative template paths used in NoteConfig.
TemplatesDirs []string
}
// NewDefaultConfig creates a new Config with the default settings.
@ -46,10 +43,9 @@ func NewDefaultConfig() Config {
MultiwordTags: false,
},
},
Filters: map[string]string{},
Aliases: map[string]string{},
Extra: map[string]string{},
TemplatesDirs: []string{},
Filters: map[string]string{},
Aliases: map[string]string{},
Extra: map[string]string{},
}
}
@ -62,29 +58,35 @@ func (c Config) RootGroupConfig() GroupConfig {
}
}
// LocateTemplate returns the absolute path for the given template path, by
// looking for it in the templates directories registered in this Config.
func (c Config) LocateTemplate(path string) (string, bool) {
if path == "" {
return "", false
}
exists := func(path string) bool {
exists, err := paths.Exists(path)
return exists && err == nil
}
if filepath.IsAbs(path) {
return path, exists(path)
// GroupConfigNamed returns the GroupConfig for the group with the given name.
// An empty name matches the root GroupConfig.
func (c Config) GroupConfigNamed(name string) (GroupConfig, error) {
if name == "" {
return c.RootGroupConfig(), nil
} else {
group, ok := c.Groups[name]
if !ok {
return GroupConfig{}, fmt.Errorf("no group named `%s` found in the config", name)
}
return group, nil
}
}
for _, dir := range c.TemplatesDirs {
if candidate := filepath.Join(dir, path); exists(candidate) {
return candidate, true
// GroupNameForPath returns the name of the GroupConfig matching the given
// path, relative to the notebook.
func (c Config) GroupNameForPath(path string) (string, error) {
for name, config := range c.Groups {
for _, groupPath := range config.Paths {
matches, err := filepath.Match(groupPath, path)
if err != nil {
return "", errors.Wrapf(err, "failed to match group %s to %s", name, path)
} else if matches {
return name, nil
}
}
}
return path, false
return "", nil
}
// FormatConfig holds the configuration for document formats, such as Markdown.
@ -132,14 +134,6 @@ type GroupConfig struct {
Extra map[string]string
}
// ConfigOverrides holds user configuration overridden values, for example fed
// from CLI flags.
type ConfigOverrides struct {
Group opt.String
BodyTemplatePath opt.String
Extra map[string]string
}
// Clone creates a copy of the GroupConfig receiver.
func (c GroupConfig) Clone() GroupConfig {
clone := c
@ -154,29 +148,16 @@ func (c GroupConfig) Clone() GroupConfig {
return clone
}
// Override modifies the GroupConfig receiver by updating the properties
// overridden in ConfigOverrides.
func (c *GroupConfig) Override(overrides ConfigOverrides) {
if !overrides.BodyTemplatePath.IsNull() {
c.Note.BodyTemplatePath = overrides.BodyTemplatePath
}
if overrides.Extra != nil {
for k, v := range overrides.Extra {
c.Extra[k] = v
}
}
}
// OpenConfig creates a new Config instance from its TOML representation stored
// in the given file.
func OpenConfig(path string, parentConfig Config) (Config, error) {
func OpenConfig(path string, parentConfig Config, fs FileStorage) (Config, error) {
// The local config is optional.
exists, err := paths.Exists(path)
exists, err := fs.FileExists(path)
if err == nil && !exists {
return parentConfig, nil
}
content, err := ioutil.ReadFile(path)
content, err := fs.Read(path)
if err != nil {
return parentConfig, errors.Wrapf(err, "failed to open config file at %s", path)
}
@ -278,8 +259,6 @@ func ParseConfig(content []byte, path string, parentConfig Config) (Config, erro
}
}
config.TemplatesDirs = append([]string{filepath.Join(filepath.Dir(path), "templates")}, config.TemplatesDirs...)
return config, nil
}

@ -1,15 +1,12 @@
package zk
package core
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/test/assert"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestParseDefaultConfig(t *testing.T) {
@ -42,10 +39,9 @@ func TestParseDefaultConfig(t *testing.T) {
Pager: opt.NullString,
FzfPreview: opt.NullString,
},
Filters: make(map[string]string),
Aliases: make(map[string]string),
Extra: make(map[string]string),
TemplatesDirs: []string{".zk/templates"},
Filters: make(map[string]string),
Aliases: make(map[string]string),
Extra: make(map[string]string),
})
}
@ -211,7 +207,6 @@ func TestParseComplete(t *testing.T) {
"hello": "world",
"salut": "le monde",
},
TemplatesDirs: []string{".zk/templates"},
})
}
@ -313,7 +308,6 @@ func TestParseMergesGroupConfig(t *testing.T) {
"hello": "world",
"salut": "le monde",
},
TemplatesDirs: []string{".zk/templates"},
})
}
@ -373,37 +367,6 @@ func TestParseIDCase(t *testing.T) {
test("unknown", CaseLower)
}
func TestLocateTemplate(t *testing.T) {
root := fmt.Sprintf("/tmp/zk-test-%d", time.Now().Unix())
os.Remove(root)
os.MkdirAll(filepath.Join(root, "templates"), os.ModePerm)
test := func(template string, expected string, exists bool) {
conf, err := ParseConfig([]byte(""), filepath.Join(root, "config.toml"), NewDefaultConfig())
assert.Nil(t, err)
path, ok := conf.LocateTemplate(template)
if exists {
assert.True(t, ok)
if path != expected {
t.Errorf("Didn't resolve template `%v` as expected: %v", template, expected)
}
} else {
assert.False(t, ok)
}
}
tpl1 := filepath.Join(root, "templates/template.tpl")
test("template.tpl", tpl1, false)
os.Create(tpl1)
test("template.tpl", tpl1, true)
tpl2 := filepath.Join(root, "abs.tpl")
test(tpl2, tpl2, false)
os.Create(tpl2)
test(tpl2, tpl2, true)
}
func TestGroupConfigClone(t *testing.T) {
original := GroupConfig{
Paths: []string{"original"},
@ -459,67 +422,3 @@ func TestGroupConfigClone(t *testing.T) {
},
})
}
func TestGroupConfigOverride(t *testing.T) {
sut := GroupConfig{
Paths: []string{"path"},
Note: NoteConfig{
FilenameTemplate: "filename",
BodyTemplatePath: opt.NewString("body.tpl"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetLetters,
Case: CaseUpper,
},
},
Extra: map[string]string{
"hello": "world",
"salut": "le monde",
},
}
// Empty overrides
sut.Override(ConfigOverrides{})
assert.Equal(t, sut, GroupConfig{
Paths: []string{"path"},
Note: NoteConfig{
FilenameTemplate: "filename",
BodyTemplatePath: opt.NewString("body.tpl"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetLetters,
Case: CaseUpper,
},
},
Extra: map[string]string{
"hello": "world",
"salut": "le monde",
},
})
// Some overrides
sut.Override(ConfigOverrides{
BodyTemplatePath: opt.NewString("overridden-template"),
Extra: map[string]string{
"hello": "overridden",
"additional": "value",
},
})
assert.Equal(t, sut, GroupConfig{
Paths: []string{"path"},
Note: NoteConfig{
FilenameTemplate: "filename",
BodyTemplatePath: opt.NewString("overridden-template"),
IDOptions: IDOptions{
Length: 4,
Charset: CharsetLetters,
Case: CaseUpper,
},
},
Extra: map[string]string{
"hello": "overridden",
"salut": "le monde",
"additional": "value",
},
})
}

@ -0,0 +1,33 @@
package core
// FileStorage is a port providing read and write access to a file storage.
type FileStorage interface {
// Abs makes the given file path absolute if needed, using the FileStorage
// working directory.
Abs(path string) (string, error)
// Rel makes the given absolute file path relative to the current working
// directory.
Rel(path string) (string, error)
// Canonical returns the canonical version of this path, resolving any
// symbolic link.
Canonical(path string) string
// FileExists returns whether a file exists at the given file path.
FileExists(path string) (bool, error)
// DirExists returns whether a directory exists at the given file path.
DirExists(path string) (bool, error)
// IsDescendantOf returns whether the given path is dir or one of its descendants.
IsDescendantOf(dir string, path string) (bool, error)
// Read returns the bytes content of the file at the given file path.
Read(path string) ([]byte, error)
// Write creates or overwrite the content at the given file path, creating
// any intermediate directories if needed.
Write(path string, content []byte) error
}

@ -0,0 +1,77 @@
package core
import (
"os"
"path/filepath"
)
// fileStorageMock implements an in-memory FileStorage for testing purposes.
type fileStorageMock struct {
// Working directory used to calculate relative paths.
WorkingDir string
// File content indexed by their path in this file storage.
Files map[string]string
// Existing directories
Dirs []string
}
func newFileStorageMock(workingDir string, dirs []string) *fileStorageMock {
return &fileStorageMock{
WorkingDir: workingDir,
Files: map[string]string{},
Dirs: dirs,
}
}
func (fs *fileStorageMock) Abs(path string) (string, error) {
var err error
if !filepath.IsAbs(path) {
path = filepath.Join(fs.WorkingDir, path)
path, err = filepath.Abs(path)
if err != nil {
return path, err
}
}
return path, nil
}
func (fs *fileStorageMock) Rel(path string) (string, error) {
return filepath.Rel(fs.WorkingDir, path)
}
func (fs *fileStorageMock) Canonical(path string) string {
return path
}
func (fs *fileStorageMock) FileExists(path string) (bool, error) {
_, ok := fs.Files[path]
return ok, nil
}
func (fs *fileStorageMock) DirExists(path string) (bool, error) {
for _, dir := range fs.Dirs {
if dir == path {
return true, nil
}
}
return false, nil
}
func (fs *fileStorageMock) fileInfo(path string) (*os.FileInfo, error) {
panic("not implemented")
}
func (fs *fileStorageMock) IsDescendantOf(dir string, path string) (bool, error) {
panic("not implemented")
}
func (fs *fileStorageMock) Read(path string) ([]byte, error) {
content, _ := fs.Files[path]
return []byte(content), nil
}
func (fs *fileStorageMock) Write(path string, content []byte) error {
fs.Files[path] = string(content)
return nil
}

@ -1,4 +1,4 @@
package zk
package core
// IDOptions holds the options used to generate an ID.
type IDOptions struct {
@ -29,3 +29,9 @@ const (
CaseUpper
CaseMixed
)
// IDGenerator is a function returning a new ID with each invocation.
type IDGenerator func() string
// IDGeneratorFactory creates a new IDGenerator function using the given IDOptions.
type IDGeneratorFactory func(opts IDOptions) func() string

@ -0,0 +1,38 @@
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
// Destination URI of the link.
Href string
// Indicates whether the target is a remote (e.g. HTTP) resource.
IsExternal bool
// Relationships between the note and the linked target.
Rels []LinkRelation
// Excerpt of the paragraph containing the note.
Snippet string
// Start byte offset of the snippet in the note content.
SnippetStart int
// End byte offset of the snippet in the note content.
SnippetEnd int
}
// LinkRelation defines the relationship between a link's source and target.
type LinkRelation string
const (
// LinkRelationDown defines the target note as a child of the source.
LinkRelationDown LinkRelation = "down"
// LinkRelationDown defines the target note as a parent of the source.
LinkRelationUp LinkRelation = "up"
)
// LinkRels creates a slice of LinkRelation from a list of strings.
func LinkRels(rel ...string) []LinkRelation {
rels := []LinkRelation{}
for _, r := range rel {
rels = append(rels, LinkRelation(r))
}
return rels
}

@ -0,0 +1,64 @@
package core
import (
"time"
)
// NoteID represents the unique ID of a note collection relative to a given
// NoteIndex implementation.
type NoteID int64
func (id NoteID) IsValid() bool {
return id > 0
}
// Note holds the metadata and content of a single note.
type Note struct {
// Unique ID of this note in a NoteRepository.
ID NoteID
// Path relative to the root of the notebook.
Path string
// Title of the note.
Title string
// First paragraph from the note body.
Lead string
// Content of the note, after any frontmatter and title heading.
Body string
// Whole raw content of the note.
RawContent string
// Number of words found in the content.
WordCount int
// List of outgoing links (internal or external) found in the content.
Links []Link
// List of tags found in the content.
Tags []string
// JSON dictionary of raw metadata extracted from the frontmatter.
Metadata map[string]interface{}
// Date of creation.
Created time.Time
// Date of last modification.
Modified time.Time
// Checksum of the note content.
Checksum string
}
// ContextualNote holds a Note and context-sensitive content snippets.
//
// This is used for example:
// * to show an excerpt with highlighted search terms
// * when following links, to print the source paragraph
type ContextualNote struct {
Note
// List of context-sensitive excerpts from the note.
Snippets []string
}
// MinimalNote holds a Note's title and path information, for display purposes.
type MinimalNote struct {
// Unique ID of this note in a notebook.
ID NoteID
// Path relative to the root of the notebook.
Path string
// Title of the note.
Title string
}

@ -1,29 +1,16 @@
package note
package core
import (
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/mickael-menu/zk/core"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/internal/util/opt"
)
// ErrCanceled is returned when the user cancelled an operation.
var ErrCanceled = errors.New("canceled")
// Finder retrieves notes matching the given options.
//
// Returns the number of matches found.
type Finder interface {
Find(opts FinderOpts) ([]Match, error)
FindByHref(href string) (*Match, error)
}
// FinderOpts holds the option used to filter and order a list of notes.
type FinderOpts struct {
// NoteFindOpts holds a set of filtering options used to find notes.
type NoteFindOpts struct {
// Filter used to match the notes with FTS predicates.
Match opt.String
// Filter by note paths.
@ -31,7 +18,7 @@ type FinderOpts struct {
// Filter excluding notes at the given paths.
ExcludePaths []string
// Filter excluding notes with the given IDs.
ExcludeIds []core.NoteId
ExcludeIDs []NoteID
// Filter by tags found in the notes.
Tags []string
// Filter the notes mentioning the given ones.
@ -54,32 +41,23 @@ type FinderOpts struct {
ModifiedStart *time.Time
// Filter notes modified before the given date.
ModifiedEnd *time.Time
// Indicates that the user should select manually the notes.
Interactive bool
// Limits the number of results
Limit int
// Sorting criteria
Sorters []Sorter
Sorters []NoteSorter
}
// ExcludingIds creates a new FinderOpts after adding the given ids to the list
// of excluded note ids.
func (o FinderOpts) ExcludingIds(ids ...core.NoteId) FinderOpts {
if o.ExcludeIds == nil {
o.ExcludeIds = []core.NoteId{}
// ExcludingID creates a new FinderOpts after adding the given ID to the list
// of excluded note IDs.
func (o NoteFindOpts) ExcludingID(id NoteID) NoteFindOpts {
if o.ExcludeIDs == nil {
o.ExcludeIDs = []NoteID{}
}
o.ExcludeIds = append(o.ExcludeIds, ids...)
o.ExcludeIDs = append(o.ExcludeIDs, id)
return o
}
// Match holds information about a note matching the find options.
type Match struct {
Metadata
// Snippets are relevant excerpts in the note.
Snippets []string
}
// LinkFilter is a note filter used to select notes linking to other ones.
type LinkFilter struct {
Paths []string
@ -88,53 +66,74 @@ type LinkFilter struct {
MaxDistance int
}
// Sorter represents an order term used to sort a list of notes.
type Sorter struct {
Field SortField
// NoteSorter represents an order term used to sort a list of notes.
type NoteSorter struct {
Field NoteSortField
Ascending bool
}
// SortField represents a note field used to sort a list of notes.
type SortField int
// NoteSortField represents a note field used to sort a list of notes.
type NoteSortField int
const (
// Sort by creation date.
SortCreated SortField = iota + 1
NoteSortCreated NoteSortField = iota + 1
// Sort by modification date.
SortModified
NoteSortModified
// Sort by the file paths.
SortPath
NoteSortPath
// Sort randomly.
SortRandom
NoteSortRandom
// Sort by the note titles.
SortTitle
NoteSortTitle
// Sort by the number of words in the note bodies.
SortWordCount
NoteSortWordCount
// Sort by the length of the note path.
// This is not accessible to the user but used for technical reasons, to
// find the best match when searching a path prefix.
NoteSortPathLength
)
// SorterFromString returns a Sorter from its string representation.
// NoteSortersFromStrings returns a list of NoteSorter from their string
// representation.
func NoteSortersFromStrings(strs []string) ([]NoteSorter, error) {
sorters := make([]NoteSorter, 0)
// Iterates in reverse order to be able to override sort criteria set in a
// config alias with a `--sort` flag.
for i := len(strs) - 1; i >= 0; i-- {
sorter, err := NoteSorterFromString(strs[i])
if err != nil {
return sorters, err
}
sorters = append(sorters, sorter)
}
return sorters, nil
}
// NoteSorterFromString returns a NoteSorter from its string representation.
//
// If the input str has for suffix `+`, then the order will be ascending, while
// descending for `-`. If no suffix is given, then the default order for the
// sorting field will be used.
func SorterFromString(str string) (Sorter, error) {
func NoteSorterFromString(str string) (NoteSorter, error) {
orderSymbol, _ := utf8.DecodeLastRuneInString(str)
str = strings.TrimRight(str, "+-")
var sorter Sorter
var sorter NoteSorter
switch str {
case "created", "c":
sorter = Sorter{Field: SortCreated, Ascending: false}
sorter = NoteSorter{Field: NoteSortCreated, Ascending: false}
case "modified", "m":
sorter = Sorter{Field: SortModified, Ascending: false}
sorter = NoteSorter{Field: NoteSortModified, Ascending: false}
case "path", "p":
sorter = Sorter{Field: SortPath, Ascending: true}
sorter = NoteSorter{Field: NoteSortPath, Ascending: true}
case "title", "t":
sorter = Sorter{Field: SortTitle, Ascending: true}
sorter = NoteSorter{Field: NoteSortTitle, Ascending: true}
case "random", "r":
sorter = Sorter{Field: SortRandom, Ascending: true}
sorter = NoteSorter{Field: NoteSortRandom, Ascending: true}
case "word-count", "wc":
sorter = Sorter{Field: SortWordCount, Ascending: true}
sorter = NoteSorter{Field: NoteSortWordCount, Ascending: true}
default:
return sorter, fmt.Errorf("%s: unknown sorting term\ntry created, modified, path, title, random or word-count", str)
}
@ -148,19 +147,3 @@ func SorterFromString(str string) (Sorter, error) {
return sorter, nil
}
// SortersFromStrings returns a list of Sorter from their string representation.
func SortersFromStrings(strs []string) ([]Sorter, error) {
sorters := make([]Sorter, 0)
// Iterates in reverse order to be able to override sort criteria set in a
// config alias with a `--sort` flag.
for i := len(strs) - 1; i >= 0; i-- {
sorter, err := SorterFromString(strs[i])
if err != nil {
return sorters, err
}
sorters = append(sorters, sorter)
}
return sorters, nil
}

@ -0,0 +1,69 @@
package core
import (
"testing"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestNoteSorterFromString(t *testing.T) {
test := func(str string, expectedField NoteSortField, expectedAscending bool) {
actual, err := NoteSorterFromString(str)
assert.Nil(t, err)
assert.Equal(t, actual, NoteSorter{Field: expectedField, Ascending: expectedAscending})
}
test("c", NoteSortCreated, false)
test("c+", NoteSortCreated, true)
test("created", NoteSortCreated, false)
test("created-", NoteSortCreated, false)
test("created+", NoteSortCreated, true)
test("m", NoteSortModified, false)
test("modified", NoteSortModified, false)
test("modified+", NoteSortModified, true)
test("p", NoteSortPath, true)
test("path", NoteSortPath, true)
test("path-", NoteSortPath, false)
test("t", NoteSortTitle, true)
test("title", NoteSortTitle, true)
test("title-", NoteSortTitle, false)
test("r", NoteSortRandom, true)
test("random", NoteSortRandom, true)
test("random-", NoteSortRandom, false)
test("wc", NoteSortWordCount, true)
test("word-count", NoteSortWordCount, true)
test("word-count-", NoteSortWordCount, false)
_, err := NoteSorterFromString("foobar")
assert.Err(t, err, "foobar: unknown sorting term")
}
func TestSortersFromStrings(t *testing.T) {
test := func(strs []string, expected []NoteSorter) {
actual, err := NoteSortersFromStrings(strs)
assert.Nil(t, err)
assert.Equal(t, actual, expected)
}
test([]string{}, []NoteSorter{})
test([]string{"created"}, []NoteSorter{
{Field: NoteSortCreated, Ascending: false},
})
// It is parsed in reverse order to be able to override sort criteria set
// in aliases.
test([]string{"c+", "title", "random"}, []NoteSorter{
{Field: NoteSortRandom, Ascending: true},
{Field: NoteSortTitle, Ascending: true},
{Field: NoteSortCreated, Ascending: true},
})
_, err := NoteSortersFromStrings([]string{"c", "foobar"})
assert.Err(t, err, "foobar: unknown sorting term")
}

@ -0,0 +1,64 @@
package core
import (
"path/filepath"
"regexp"
"time"
)
// NoteFormatter formats notes to be printed on the screen.
type NoteFormatter func(note ContextualNote) (string, error)
func newNoteFormatter(basePath string, template Template, fs FileStorage) (NoteFormatter, error) {
termRepl, err := template.Styler().Style("$1", StyleTerm)
if err != nil {
return nil, err
}
return func(note ContextualNote) (string, error) {
path, err := fs.Rel(filepath.Join(basePath, note.Path))
if err != nil {
return "", err
}
snippets := make([]string, 0)
for _, snippet := range note.Snippets {
snippets = append(snippets, noteTermRegex.ReplaceAllString(snippet, termRepl))
}
return template.Render(noteFormatRenderContext{
Path: path,
Title: note.Title,
Lead: note.Lead,
Body: note.Body,
Snippets: snippets,
Tags: note.Tags,
RawContent: note.RawContent,
WordCount: note.WordCount,
Metadata: note.Metadata,
Created: note.Created,
Modified: note.Modified,
Checksum: note.Checksum,
})
}, nil
}
var noteTermRegex = regexp.MustCompile(`<zk:match>(.*?)</zk:match>`)
// noteFormatRenderContext holds the variables available to the note formatting
// templates.
type noteFormatRenderContext struct {
Path string
Title string
Lead string
Body string
Snippets []string
RawContent string `handlebars:"raw-content"`
WordCount int `handlebars:"word-count"`
Tags []string
Metadata map[string]interface{}
Created time.Time
Modified time.Time
Checksum string
Env map[string]string
}

@ -0,0 +1,202 @@
package core
import (
"testing"
"time"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestNewNoteFormatter(t *testing.T) {
test := formatTest{
format: "format",
}
test.setup()
var date1 = time.Date(2009, 1, 17, 20, 34, 58, 651387237, time.UTC)
var date2 = time.Date(2009, 2, 17, 20, 34, 58, 651387237, time.UTC)
var date3 = time.Date(2009, 3, 17, 20, 34, 58, 651387237, time.UTC)
var date4 = time.Date(2009, 4, 17, 20, 34, 58, 651387237, time.UTC)
formatter, err := test.run("format")
assert.Nil(t, err)
assert.Equal(t, test.receivedLang, "fr")
res, err := formatter(ContextualNote{
Note: Note{
ID: 1,
Path: "note1",
Title: "Note 1",
Lead: "Lead 1",
Body: "Body 1",
RawContent: "Content 1",
WordCount: 1,
Tags: []string{"tag1", "tag2"},
Metadata: map[string]interface{}{
"metadata1": "val1",
"metadata2": "val2",
},
Created: date1,
Modified: date2,
Checksum: "checksum1",
},
Snippets: []string{"snippet1", "snippet2"},
})
assert.Nil(t, err)
assert.Equal(t, res, "format")
res, err = formatter(ContextualNote{
Note: Note{
ID: 2,
Path: "dir/note2",
Title: "Note 2",
Lead: "Lead 2",
Body: "Body 2",
RawContent: "Content 2",
WordCount: 2,
Tags: []string{},
Metadata: map[string]interface{}{},
Created: date3,
Modified: date4,
Checksum: "checksum2",
},
Snippets: []string{},
})
assert.Nil(t, err)
assert.Equal(t, res, "format")
// Check that the template received the proper contexts
assert.Equal(t, test.template.Contexts, []interface{}{
noteFormatRenderContext{
Path: "note1",
Title: "Note 1",
Lead: "Lead 1",
Body: "Body 1",
Snippets: []string{"snippet1", "snippet2"},
RawContent: "Content 1",
WordCount: 1,
Tags: []string{"tag1", "tag2"},
Metadata: map[string]interface{}{
"metadata1": "val1",
"metadata2": "val2",
},
Created: date1,
Modified: date2,
Checksum: "checksum1",
},
noteFormatRenderContext{
Path: "dir/note2",
Title: "Note 2",
Lead: "Lead 2",
Body: "Body 2",
Snippets: []string{},
RawContent: "Content 2",
WordCount: 2,
Tags: []string{},
Metadata: map[string]interface{}{},
Created: date3,
Modified: date4,
Checksum: "checksum2",
},
})
}
func TestNoteFormatterMakesPathRelative(t *testing.T) {
test := func(basePath, currentPath, path string, expected string) {
test := formatTest{
rootDir: basePath,
workingDir: currentPath,
}
test.setup()
formatter, err := test.run("format")
assert.Nil(t, err)
_, err = formatter(ContextualNote{
Note: Note{Path: path},
})
assert.Nil(t, err)
assert.Equal(t, test.template.Contexts, []interface{}{
noteFormatRenderContext{
Path: expected,
Snippets: []string{},
},
})
}
// Check that the path is relative to the current directory.
test("", "", "note.md", "note.md")
test("", "", "dir/note.md", "dir/note.md")
test("/abs/zk", "/abs/zk", "note.md", "note.md")
test("/abs/zk", "/abs/zk", "dir/note.md", "dir/note.md")
test("/abs/zk", "/abs/zk/dir", "note.md", "../note.md")
test("/abs/zk", "/abs/zk/dir", "dir/note.md", "note.md")
test("/abs/zk", "/abs", "note.md", "zk/note.md")
test("/abs/zk", "/abs", "dir/note.md", "zk/dir/note.md")
}
func TestNoteFormatterStylesSnippetTerm(t *testing.T) {
test := func(snippet string, expected string) {
test := formatTest{}
test.setup()
formatter, err := test.run("format")
assert.Nil(t, err)
_, err = formatter(ContextualNote{
Snippets: []string{snippet},
})
assert.Nil(t, err)
assert.Equal(t, test.template.Contexts, []interface{}{
noteFormatRenderContext{
Path: ".",
Snippets: []string{expected},
},
})
}
test("Hello world!", "Hello world!")
test("Hello <zk:match>world</zk:match>!", "Hello term(world)!")
test("Hello <zk:match>world</zk:match> with <zk:match>several matches</zk:match>!", "Hello term(world) with term(several matches)!")
test("Hello <zk:match>world</zk:match> with <zk:match>several<zk:match> matches</zk:match>!", "Hello term(world) with term(several<zk:match> matches)!")
}
// formatTest builds and runs the SUT for note formatter test cases.
type formatTest struct {
format string
rootDir string
workingDir string
fs *fileStorageMock
config Config
templateLoader *templateLoaderMock
template *templateSpy
receivedLang string
}
func (t *formatTest) setup() {
if t.format == "" {
t.format = "format"
}
if t.rootDir == "" {
t.rootDir = "/notebook"
}
if t.workingDir == "" {
t.workingDir = t.rootDir
}
t.fs = newFileStorageMock(t.workingDir, []string{})
t.templateLoader = newTemplateLoaderMock()
t.template = t.templateLoader.SpyString(t.format)
t.config = NewDefaultConfig()
t.config.Note.Lang = "fr"
}
func (t *formatTest) run(format string) (NoteFormatter, error) {
notebook := NewNotebook(t.rootDir, t.config, NotebookPorts{
TemplateLoaderFactory: func(language string) (TemplateLoader, error) {
t.receivedLang = language
return t.templateLoader, nil
},
FS: t.fs,
})
return notebook.NewNoteFormatter(format)
}

@ -0,0 +1,217 @@
package core
import (
"crypto/sha256"
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"time"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/paths"
strutil "github.com/mickael-menu/zk/internal/util/strings"
"github.com/relvacode/iso8601"
"gopkg.in/djherbis/times.v1"
)
// NoteIndex persists and grants access to indexed information about the notes.
type NoteIndex interface {
// Find retrieves the notes matching the given filtering and sorting criteria.
Find(opts NoteFindOpts) ([]ContextualNote, error)
// FindMinimal retrieves lightweight metadata for the notes matching the
// given filtering and sorting criteria.
FindMinimal(opts NoteFindOpts) ([]MinimalNote, error)
// FindCollections retrieves all the collections of the given kind.
FindCollections(kind CollectionKind) ([]Collection, error)
// Indexed returns the list of indexed note file metadata.
IndexedPaths() (<-chan paths.Metadata, error)
// Add indexes a new note from its metadata.
Add(note Note) (NoteID, error)
// Update resets the metadata of an already indexed note.
Update(note Note) error
// Remove deletes a note from the index.
Remove(path string) error
// Commit performs a set of operations atomically.
Commit(transaction func(idx NoteIndex) error) error
// NeedsReindexing returns whether all notes should be reindexed.
NeedsReindexing() (bool, error)
// SetNeedsReindexing indicates whether all notes should be reindexed.
SetNeedsReindexing(needsReindexing bool) error
}
// NoteIndexingStats holds statistics about a notebook indexing process.
type NoteIndexingStats struct {
// Number of notes in the source.
SourceCount int
// Number of newly indexed notes.
AddedCount int
// Number of notes modified since last indexing.
ModifiedCount int
// Number of notes removed since last indexing.
RemovedCount int
// Duration of the indexing process.
Duration time.Duration
}
// String implements Stringer
func (s NoteIndexingStats) String() string {
return fmt.Sprintf(`Indexed %d %v in %v
+ %d added
~ %d modified
- %d removed`,
s.SourceCount,
strutil.Pluralize("note", s.SourceCount),
s.Duration.Round(500*time.Millisecond),
s.AddedCount, s.ModifiedCount, s.RemovedCount,
)
}
// indexTask indexes the notes in the given directory with the NoteIndex.
type indexTask struct {
notebook *Notebook
force bool
index NoteIndex
parser NoteParser
logger util.Logger
}
func (t *indexTask) execute(callback func(change paths.DiffChange)) (NoteIndexingStats, error) {
wrap := errors.Wrapper("indexing failed")
stats := NoteIndexingStats{}
startTime := time.Now()
needsReindexing, err := t.index.NeedsReindexing()
if err != nil {
return stats, wrap(err)
}
force := t.force || needsReindexing
// FIXME: Use Extension defined in each DirConfig.
source := paths.Walk(t.notebook.Path, t.notebook.Config.Note.Extension, t.logger)
target, err := t.index.IndexedPaths()
if err != nil {
return stats, wrap(err)
}
// FIXME: Use the FS?
count, err := paths.Diff(source, target, force, func(change paths.DiffChange) error {
callback(change)
switch change.Kind {
case paths.DiffAdded:
stats.AddedCount += 1
note, err := t.noteAt(change.Path)
if err == nil {
_, err = t.index.Add(note)
}
t.logger.Err(err)
case paths.DiffModified:
stats.ModifiedCount += 1
note, err := t.noteAt(change.Path)
if err == nil {
err = t.index.Update(note)
}
t.logger.Err(err)
case paths.DiffRemoved:
stats.RemovedCount += 1
err := t.index.Remove(change.Path)
t.logger.Err(err)
}
return nil
})
stats.SourceCount = count
stats.Duration = time.Since(startTime)
if needsReindexing {
err = t.index.SetNeedsReindexing(false)
}
return stats, wrap(err)
}
// noteAt parses a Note at the given path.
func (t *indexTask) noteAt(path string) (Note, error) {
note := Note{
Path: path,
Links: []Link{},
Tags: []string{},
}
absPath := filepath.Join(t.notebook.Path, path)
content, err := ioutil.ReadFile(absPath)
if err != nil {
return note, err
}
contentStr := string(content)
contentParts, err := t.parser.Parse(contentStr)
if err != nil {
return note, err
}
note.Title = contentParts.Title.String()
note.Lead = contentParts.Lead.String()
note.Body = contentParts.Body.String()
note.RawContent = contentStr
note.WordCount = len(strings.Fields(contentStr))
note.Links = make([]Link, 0)
note.Tags = contentParts.Tags
note.Metadata = contentParts.Metadata
note.Checksum = fmt.Sprintf("%x", sha256.Sum256(content))
for _, link := range contentParts.Links {
if !strutil.IsURL(link.Href) {
// Make the href relative to the notebook root.
href := filepath.Join(filepath.Dir(absPath), link.Href)
link.Href, err = t.notebook.RelPath(href)
if err != nil {
return note, err
}
}
note.Links = append(note.Links, link)
}
times, err := times.Stat(absPath)
if err != nil {
return note, err
}
note.Modified = times.ModTime().UTC()
note.Created = t.creationDateFrom(note.Metadata, times)
return note, nil
}
func (t *indexTask) creationDateFrom(metadata map[string]interface{}, times times.Timespec) time.Time {
// Read the creation date from the YAML frontmatter `date` key.
if dateVal, ok := metadata["date"]; ok {
if dateStr, ok := dateVal.(string); ok {
if time, err := iso8601.ParseString(dateStr); err == nil {
return time
}
// Omitting the `T` is common
if time, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil {
return time
}
if time, err := time.Parse("2006-01-02 15:04", dateStr); err == nil {
return time
}
}
}
if times.HasBirthTime() {
return times.BirthTime().UTC()
}
return time.Now().UTC()
}

@ -0,0 +1,107 @@
package core
import (
"path/filepath"
"time"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/paths"
)
type newNoteTask struct {
dir Dir
title string
content string
date time.Time
extra map[string]string
env map[string]string
fs FileStorage
filenameTemplate string
bodyTemplatePath opt.String
templates TemplateLoader
genID IDGenerator
}
func (t *newNoteTask) execute() (string, error) {
filenameTemplate, err := t.templates.LoadTemplate(t.filenameTemplate)
if err != nil {
return "", err
}
var contentTemplate Template = NullTemplate
if templatePath := t.bodyTemplatePath.Unwrap(); templatePath != "" {
contentTemplate, err = t.templates.LoadTemplateAt(templatePath)
if err != nil {
return "", err
}
}
context := newNoteTemplateContext{
Title: t.title,
Content: t.content,
Dir: t.dir.Name,
Extra: t.extra,
Now: t.date,
Env: t.env,
}
path, context, err := t.generatePath(context, filenameTemplate)
if err != nil {
return "", err
}
content, err := contentTemplate.Render(context)
if err != nil {
return "", err
}
err = t.fs.Write(path, []byte(content))
if err != nil {
return "", err
}
return path, nil
}
func (c *newNoteTask) generatePath(context newNoteTemplateContext, filenameTemplate Template) (string, newNoteTemplateContext, error) {
var err error
var filename string
var path string
for i := 0; i < 50; i++ {
context.ID = c.genID()
filename, err = filenameTemplate.Render(context)
if err != nil {
return "", context, err
}
path = filepath.Join(c.dir.Path, filename)
exists, err := c.fs.FileExists(path)
if err != nil {
return "", context, err
} else if !exists {
context.Filename = filepath.Base(path)
context.FilenameStem = paths.FilenameStem(path)
return path, context, nil
}
}
return "", context, ErrNoteExists{
Name: filepath.Join(c.dir.Name, filename),
Path: path,
}
}
// newNoteTemplateContext holds the placeholder values which will be expanded in the templates.
type newNoteTemplateContext struct {
ID string `handlebars:"id"`
Title string
Content string
Dir string
Filename string
FilenameStem string `handlebars:"filename-stem"`
Extra map[string]string
Now time.Time
Env map[string]string
}

@ -0,0 +1,487 @@
package core
import (
"fmt"
"testing"
"time"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/opt"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestNotebookNewNote(t *testing.T) {
test := newNoteTest{
rootDir: "/notebook",
}
test.setup()
path, err := test.run(NewNoteOpts{
Title: opt.NewString("Note title"),
Content: "Note content",
Extra: map[string]string{
"add-extra": "ec83da",
},
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/filename.ext")
// Check created note.
assert.Equal(t, test.fs.Files[path], "body")
assert.Equal(t, test.receivedLang, test.config.Note.Lang)
assert.Equal(t, test.receivedIDOpts, test.config.Note.IDOptions)
// Check that the templates received the proper render contexts.
assert.Equal(t, test.filenameTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Note title",
Content: "Note content",
Dir: "",
Filename: "",
FilenameStem: "",
Extra: map[string]string{"add-extra": "ec83da", "conf-extra": "38srnw"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
assert.Equal(t, test.bodyTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Note title",
Content: "Note content",
Dir: "",
Filename: "filename.ext",
FilenameStem: "filename",
Extra: map[string]string{"add-extra": "ec83da", "conf-extra": "38srnw"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
}
func TestNotebookNewNoteWithDefaultTitle(t *testing.T) {
test := newNoteTest{
rootDir: "/notebook",
}
test.setup()
_, err := test.run(NewNoteOpts{
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, test.filenameTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Titre par défaut",
Extra: map[string]string{"conf-extra": "38srnw"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
}
func TestNotebookNewNoteInUnknownDir(t *testing.T) {
test := newNoteTest{
rootDir: "/notebook",
}
test.setup()
_, err := test.run(NewNoteOpts{
Directory: opt.NewString("a-dir"),
})
assert.Err(t, err, "a-dir: directory not found")
}
func TestNotebookNewNoteInDir(t *testing.T) {
test := newNoteTest{
rootDir: "/notebook",
dirs: []string{"/notebook/a-dir"},
}
test.setup()
path, err := test.run(NewNoteOpts{
Title: opt.NewString("Note title"),
Directory: opt.NewString("a-dir"),
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/a-dir/filename.ext")
// Check created note.
assert.Equal(t, test.fs.Files[path], "body")
// Check that the templates received the proper render contexts.
assert.Equal(t, test.filenameTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Note title",
Content: "",
Dir: "a-dir",
Filename: "",
FilenameStem: "",
Extra: map[string]string{"conf-extra": "38srnw"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
assert.Equal(t, test.bodyTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Note title",
Content: "",
Dir: "a-dir",
Filename: "filename.ext",
FilenameStem: "filename",
Extra: map[string]string{"conf-extra": "38srnw"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
}
// Create a note in a directory belonging to a config group which will override
// the default config.
func TestNotebookNewNoteInDirWithGroup(t *testing.T) {
groupConfig := GroupConfig{
Paths: []string{"a-dir"},
Note: NoteConfig{
DefaultTitle: "Group default title",
FilenameTemplate: "group-filename",
BodyTemplatePath: opt.NewString("group-body"),
Extension: "group-ext",
Lang: "de",
IDOptions: IDOptions{
Length: 29,
Charset: []rune("group"),
Case: CaseMixed,
},
},
Extra: map[string]string{
"group-extra": "e48rs",
},
}
test := newNoteTest{
rootDir: "/notebook",
dirs: []string{"/notebook/a-dir"},
groups: map[string]GroupConfig{
"group-a": groupConfig,
},
}
test.setup()
filenameTemplate := test.templateLoader.SpyString("group-filename.group-ext")
bodyTemplate := test.templateLoader.SpyFile("group-body", "group template body")
path, err := test.run(NewNoteOpts{
Directory: opt.NewString("a-dir"),
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/a-dir/group-filename.group-ext")
// Check created note.
assert.Equal(t, test.fs.Files[path], "group template body")
assert.Equal(t, test.receivedLang, groupConfig.Note.Lang)
assert.Equal(t, test.receivedIDOpts, groupConfig.Note.IDOptions)
// Check that the templates received the proper render contexts.
assert.Equal(t, filenameTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Group default title",
Content: "",
Dir: "a-dir",
Filename: "",
FilenameStem: "",
Extra: map[string]string{"group-extra": "e48rs"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
assert.Equal(t, bodyTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Group default title",
Content: "",
Dir: "a-dir",
Filename: "group-filename.group-ext",
FilenameStem: "group-filename",
Extra: map[string]string{"group-extra": "e48rs"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
}
// Create a note with an explicit group overriding the default config.
func TestNotebookNewNoteWithGroup(t *testing.T) {
groupConfig := GroupConfig{
Paths: []string{"a-dir"},
Note: NoteConfig{
DefaultTitle: "Group default title",
FilenameTemplate: "group-filename",
BodyTemplatePath: opt.NewString("group-body"),
Extension: "group-ext",
Lang: "de",
IDOptions: IDOptions{
Length: 29,
Charset: []rune("group"),
Case: CaseMixed,
},
},
Extra: map[string]string{
"group-extra": "e48rs",
},
}
test := newNoteTest{
rootDir: "/notebook",
groups: map[string]GroupConfig{
"group-a": groupConfig,
},
}
test.setup()
filenameTemplate := test.templateLoader.SpyString("group-filename.group-ext")
bodyTemplate := test.templateLoader.SpyFile("group-body", "group template body")
path, err := test.run(NewNoteOpts{
Group: opt.NewString("group-a"),
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/group-filename.group-ext")
// Check created note.
assert.Equal(t, test.fs.Files[path], "group template body")
assert.Equal(t, test.receivedLang, groupConfig.Note.Lang)
assert.Equal(t, test.receivedIDOpts, groupConfig.Note.IDOptions)
// Check that the templates received the proper render contexts.
assert.Equal(t, filenameTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Group default title",
Content: "",
Dir: "",
Filename: "",
FilenameStem: "",
Extra: map[string]string{"group-extra": "e48rs"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
assert.Equal(t, bodyTemplate.Contexts, []interface{}{
newNoteTemplateContext{
ID: "id",
Title: "Group default title",
Content: "",
Dir: "",
Filename: "group-filename.group-ext",
FilenameStem: "group-filename",
Extra: map[string]string{"group-extra": "e48rs"},
Now: now,
Env: map[string]string{"KEY1": "foo", "KEY2": "bar"},
},
})
}
func TestNotebookNewNoteWithUnknownGroup(t *testing.T) {
test := newNoteTest{
rootDir: "/notebook",
}
test.setup()
_, err := test.run(NewNoteOpts{
Group: opt.NewString("group-a"),
Date: now,
})
assert.Err(t, err, "no group named `group-a` found in the config")
}
func TestNotebookNewNoteWithCustomTemplate(t *testing.T) {
test := newNoteTest{
rootDir: "/notebook",
}
test.setup()
test.templateLoader.SpyFile("custom-body", "custom body template")
path, err := test.run(NewNoteOpts{
Template: opt.NewString("custom-body"),
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, test.fs.Files[path], "custom body template")
}
// Tries to generate a filename until one is free.
func TestNotebookNewNoteTriesUntilFreePath(t *testing.T) {
test := newNoteTest{
rootDir: "/notebook",
files: map[string]string{
"/notebook/filename1.ext": "file1",
"/notebook/filename2.ext": "file2",
"/notebook/filename3.ext": "file3",
},
filenameTemplateRender: func(context newNoteTemplateContext) string {
return "filename" + context.ID + ".ext"
},
idGeneratorFactory: incrementingID,
}
test.setup()
path, err := test.run(NewNoteOpts{
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/filename4.ext")
// Check created note.
assert.Equal(t, test.fs.Files[path], "body")
}
func TestNotebookNewNoteErrorWhenNoFreePath(t *testing.T) {
files := map[string]string{}
for i := 1; i < 51; i++ {
files[fmt.Sprintf("/notebook/filename%d.ext", i)] = "body"
}
test := newNoteTest{
rootDir: "/notebook",
files: files,
filenameTemplateRender: func(context newNoteTemplateContext) string {
return "filename" + context.ID + ".ext"
},
idGeneratorFactory: incrementingID,
}
test.setup()
_, err := test.run(NewNoteOpts{
Date: now,
})
assert.Err(t, err, "/notebook/filename50.ext: note already exists")
assert.Equal(t, test.fs.Files, files)
}
var now = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC)
// newNoteTest builds and runs the SUT for new note test cases.
type newNoteTest struct {
rootDir string
files map[string]string
dirs []string
fs *fileStorageMock
config Config
groups map[string]GroupConfig
templateLoader *templateLoaderMock
filenameTemplateRender func(context newNoteTemplateContext) string
filenameTemplate *templateSpy
bodyTemplate *templateSpy
idGeneratorFactory IDGeneratorFactory
osEnv map[string]string
receivedLang string
receivedIDOpts IDOptions
}
func (t *newNoteTest) setup() {
if t.rootDir == "" {
t.rootDir = "/notebook"
}
if t.dirs == nil {
t.dirs = []string{}
}
t.dirs = append(t.dirs, t.rootDir)
t.fs = newFileStorageMock(t.rootDir, t.dirs)
if t.files != nil {
t.fs.Files = t.files
}
t.templateLoader = newTemplateLoaderMock()
if t.filenameTemplateRender != nil {
t.filenameTemplate = t.templateLoader.Spy("filename.ext", func(context interface{}) string {
return t.filenameTemplateRender(context.(newNoteTemplateContext))
})
} else {
t.filenameTemplate = t.templateLoader.SpyString("filename.ext")
}
t.bodyTemplate = t.templateLoader.SpyFile("default", "body")
if t.idGeneratorFactory == nil {
t.idGeneratorFactory = func(opts IDOptions) func() string {
return func() string { return "id" }
}
}
if t.osEnv == nil {
t.osEnv = map[string]string{
"KEY1": "foo",
"KEY2": "bar",
}
}
if t.groups == nil {
t.groups = map[string]GroupConfig{}
}
t.config = Config{
Note: NoteConfig{
FilenameTemplate: "filename",
Extension: "ext",
BodyTemplatePath: opt.NewString("default"),
Lang: "fr",
DefaultTitle: "Titre par défaut",
IDOptions: IDOptions{
Length: 42,
Charset: []rune("hello"),
Case: CaseUpper,
},
},
Groups: t.groups,
Extra: map[string]string{
"conf-extra": "38srnw",
},
}
}
func (t *newNoteTest) run(opts NewNoteOpts) (string, error) {
notebook := NewNotebook(t.rootDir, t.config, NotebookPorts{
TemplateLoaderFactory: func(language string) (TemplateLoader, error) {
t.receivedLang = language
return t.templateLoader, nil
},
IDGeneratorFactory: func(opts IDOptions) func() string {
t.receivedIDOpts = opts
return t.idGeneratorFactory(opts)
},
FS: t.fs,
Logger: &util.NullLogger,
OSEnv: func() map[string]string { return t.osEnv },
})
return notebook.NewNote(opts)
}
// incrementingID returns a generator of incrementing string ID.
func incrementingID(opts IDOptions) func() string {
i := 0
return func() string {
i++
return fmt.Sprintf("%d", i)
}
}

@ -0,0 +1,26 @@
package core
import (
"github.com/mickael-menu/zk/internal/util/opt"
)
// NoteParser parses a note's raw content into its components.
type NoteParser interface {
Parse(content string) (*ParsedNote, error)
}
// ParsedNote holds the data parsed from the note content.
type ParsedNote struct {
// Title is the heading of the note.
Title opt.String
// Lead is the opening paragraph or section of the note.
Lead opt.String
// Body is the content of the note, including the Lead but without the Title.
Body opt.String
// Tags is the list of tags found in the note content.
Tags []string
// Links is the list of outbound links found in the note.
Links []Link
// Additional metadata. For example, extracted from a YAML frontmatter.
Metadata map[string]interface{}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save