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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mickael-menu/zk/util/test/assert"
|
"github.com/mickael-menu/zk/internal/util/test/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExpandNamedFiltersNone(t *testing.T) {
|
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