mirror of https://github.com/mickael-menu/zk
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
parent
dd561be1a7
commit
50855154e2
@ -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, ¬eExists) {
|
||||
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
|
||||
}
|
@ -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}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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))
|
||||
})
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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}}")
|
||||
}
|
@ -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, ¬eExists) {
|
||||
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,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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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…
Reference in New Issue