diff --git a/adapter/container.go b/adapter/container.go
deleted file mode 100644
index a45ac50..0000000
--- a/adapter/container.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/adapter/fzf/finder.go b/adapter/fzf/finder.go
deleted file mode 100644
index ca4241e..0000000
--- a/adapter/fzf/finder.go
+++ /dev/null
@@ -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
-}
diff --git a/adapter/handlebars/handlebars.go b/adapter/handlebars/handlebars.go
deleted file mode 100644
index 80863fc..0000000
--- a/adapter/handlebars/handlebars.go
+++ /dev/null
@@ -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
-}
diff --git a/adapter/sqlite/fixtures/sample.db b/adapter/sqlite/fixtures/sample.db
deleted file mode 100644
index a584291..0000000
--- a/adapter/sqlite/fixtures/sample.db
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1395602c94007495dec225bc4f1062aa7fe9a6188ba28c6419fe1e132f41778a
-size 36864
diff --git a/adapter/sqlite/note_indexer.go b/adapter/sqlite/note_indexer.go
deleted file mode 100644
index 8b3de50..0000000
--- a/adapter/sqlite/note_indexer.go
+++ /dev/null
@@ -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)
-}
diff --git a/adapter/sqlite/note_indexer_test.go b/adapter/sqlite/note_indexer_test.go
deleted file mode 100644
index edf3d90..0000000
--- a/adapter/sqlite/note_indexer_test.go
+++ /dev/null
@@ -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)
-}
diff --git a/adapter/sqlite/transaction_test.go b/adapter/sqlite/transaction_test.go
deleted file mode 100644
index 0937874..0000000
--- a/adapter/sqlite/transaction_test.go
+++ /dev/null
@@ -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
-}
diff --git a/adapter/term/prompt.go b/adapter/term/prompt.go
deleted file mode 100644
index c115cee..0000000
--- a/adapter/term/prompt.go
+++ /dev/null
@@ -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
-}
diff --git a/adapter/term/styler.go b/adapter/term/styler.go
deleted file mode 100644
index ac61d9e..0000000
--- a/adapter/term/styler.go
+++ /dev/null
@@ -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
-}
diff --git a/cmd/init.go b/cmd/init.go
deleted file mode 100644
index 4089607..0000000
--- a/cmd/init.go
+++ /dev/null
@@ -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
-}
diff --git a/cmd/list.go b/cmd/list.go
deleted file mode 100644
index 198dc14..0000000
--- a/cmd/list.go
+++ /dev/null
@@ -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
-}
diff --git a/cmd/new.go b/cmd/new.go
deleted file mode 100644
index 87c4a90..0000000
--- a/cmd/new.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/core/ids.go b/core/ids.go
deleted file mode 100644
index dc9eb93..0000000
--- a/core/ids.go
+++ /dev/null
@@ -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
-}
diff --git a/core/note/create.go b/core/note/create.go
deleted file mode 100644
index e2bf31b..0000000
--- a/core/note/create.go
+++ /dev/null
@@ -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,
- }
-}
diff --git a/core/note/create_test.go b/core/note/create_test.go
deleted file mode 100644
index 49e1acd..0000000
--- a/core/note/create_test.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/core/note/edit.go b/core/note/edit.go
deleted file mode 100644
index a226945..0000000
--- a/core/note/edit.go
+++ /dev/null
@@ -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...) + " (.*?)`)
-
-// 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
-}
diff --git a/core/note/format_test.go b/core/note/format_test.go
deleted file mode 100644
index ce69fbf..0000000
--- a/core/note/format_test.go
+++ /dev/null
@@ -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 world!", "Hello term(world)!")
- test("Hello world with several matches!", "Hello term(world) with term(several matches)!")
- test("Hello world with several matches!", "Hello term(world) with term(several 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
-}
diff --git a/core/note/index.go b/core/note/index.go
deleted file mode 100644
index 88ad00a..0000000
--- a/core/note/index.go
+++ /dev/null
@@ -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()
-}
diff --git a/core/note/parse.go b/core/note/parse.go
deleted file mode 100644
index 45be872..0000000
--- a/core/note/parse.go
+++ /dev/null
@@ -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"
-)
diff --git a/core/note/util_test.go b/core/note/util_test.go
deleted file mode 100644
index 02d4185..0000000
--- a/core/note/util_test.go
+++ /dev/null
@@ -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
-}
diff --git a/core/style/style.go b/core/style/style.go
deleted file mode 100644
index bce947b..0000000
--- a/core/style/style.go
+++ /dev/null
@@ -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
-}
diff --git a/core/templ/templ.go b/core/templ/templ.go
deleted file mode 100644
index 104aa0f..0000000
--- a/core/templ/templ.go
+++ /dev/null
@@ -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
-}
diff --git a/core/zk/zk_test.go b/core/zk/zk_test.go
deleted file mode 100644
index d0b8898..0000000
--- a/core/zk/zk_test.go
+++ /dev/null
@@ -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",
- },
- })
-}
diff --git a/internal/adapter/editor/editor.go b/internal/adapter/editor/editor.go
new file mode 100644
index 0000000..39ec0ae
--- /dev/null
+++ b/internal/adapter/editor/editor.go
@@ -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)", 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
+}
diff --git a/internal/adapter/handlebars/handlebars.go b/internal/adapter/handlebars/handlebars.go
new file mode 100644
index 0000000..bf615f7
--- /dev/null
+++ b/internal/adapter/handlebars/handlebars.go
@@ -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}
+}
diff --git a/adapter/handlebars/handlebars_test.go b/internal/adapter/handlebars/handlebars_test.go
similarity index 69%
rename from adapter/handlebars/handlebars_test.go
rename to internal/adapter/handlebars/handlebars_test.go
index 6300f44..a1a816b 100644
--- a/adapter/handlebars/handlebars_test.go
+++ b/internal/adapter/handlebars/handlebars_test.go
@@ -2,28 +2,31 @@ package handlebars
import (
"fmt"
+ "os"
+ "path/filepath"
"testing"
"time"
- "github.com/mickael-menu/zk/core/style"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/fixtures"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/fixtures"
+ "github.com/mickael-menu/zk/internal/util/paths"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func init() {
- Init("en", true, &util.NullLogger, &styler{})
+ Init(true, &util.NullLogger)
}
// styler is a test double for core.Styler
// "hello", "red" -> "red(hello)"
type styler struct{}
-func (s *styler) Style(text string, rules ...style.Rule) (string, error) {
+func (s *styler) Style(text string, rules ...core.Style) (string, error) {
return s.MustStyle(text, rules...), nil
}
-func (s *styler) MustStyle(text string, rules ...style.Rule) string {
+func (s *styler) MustStyle(text string, rules ...core.Style) string {
for _, rule := range rules {
text = fmt.Sprintf("%s(%s)", rule, text)
}
@@ -31,9 +34,9 @@ func (s *styler) MustStyle(text string, rules ...style.Rule) string {
}
func testString(t *testing.T, template string, context interface{}, expected string) {
- sut := NewLoader()
+ sut := testLoader([]string{})
- templ, err := sut.Load(template)
+ templ, err := sut.LoadTemplate(template)
assert.Nil(t, err)
actual, err := templ.Render(context)
@@ -42,9 +45,9 @@ func testString(t *testing.T, template string, context interface{}, expected str
}
func testFile(t *testing.T, name string, context interface{}, expected string) {
- sut := NewLoader()
+ sut := testLoader([]string{})
- templ, err := sut.LoadFile(fixtures.Path(name))
+ templ, err := sut.LoadTemplateAt(fixtures.Path(name))
assert.Nil(t, err)
actual, err := templ.Render(context)
@@ -52,6 +55,45 @@ func testFile(t *testing.T, name string, context interface{}, expected string) {
assert.Equal(t, actual, expected)
}
+func TestLookupPaths(t *testing.T) {
+ root := fmt.Sprintf("/tmp/zk-test-%d", time.Now().Unix())
+ os.Remove(root)
+ path1 := filepath.Join(root, "1")
+ os.MkdirAll(path1, os.ModePerm)
+ path2 := filepath.Join(root, "1")
+ os.MkdirAll(filepath.Join(path2, "subdir"), os.ModePerm)
+
+ sut := testLoader([]string{path1, path2})
+
+ test := func(path string, expected string) {
+ tpl, err := sut.LoadTemplateAt(path)
+ assert.Nil(t, err)
+ res, err := tpl.Render(nil)
+ assert.Nil(t, err)
+ assert.Equal(t, res, expected)
+ }
+
+ test1 := filepath.Join(path1, "test1.tpl")
+
+ tpl1, err := sut.LoadTemplateAt(test1)
+ assert.Err(t, err, "cannot find template at "+test1)
+ assert.Nil(t, tpl1)
+
+ paths.WriteString(test1, "Test 1")
+ test(test1, "Test 1") // absolute
+ test("test1.tpl", "Test 1") // relative
+
+ test2 := filepath.Join(path2, "test2.tpl")
+ paths.WriteString(test2, "Test 2")
+ test(test2, "Test 2") // absolute
+ test("test2.tpl", "Test 2") // relative
+
+ test3 := filepath.Join(path2, "subdir/test3.tpl")
+ paths.WriteString(test3, "Test 3")
+ test(test3, "Test 3") // absolute
+ test("subdir/test3.tpl", "Test 3") // relative
+}
+
func TestRenderString(t *testing.T) {
testString(t,
"Goodbye, {{name}}",
@@ -183,3 +225,12 @@ func TestStyleHelper(t *testing.T) {
// block
testString(t, "{{#style 'single'}}A multiline\ntext{{/style}}", nil, "single(A multiline\ntext)")
}
+
+func testLoader(lookupPaths []string) *Loader {
+ return NewLoader(LoaderOpts{
+ LookupPaths: lookupPaths,
+ Lang: "en",
+ Styler: &styler{},
+ Logger: &util.NullLogger,
+ })
+}
diff --git a/adapter/handlebars/helpers/concat.go b/internal/adapter/handlebars/helpers/concat.go
similarity index 100%
rename from adapter/handlebars/helpers/concat.go
rename to internal/adapter/handlebars/helpers/concat.go
diff --git a/adapter/handlebars/helpers/date.go b/internal/adapter/handlebars/helpers/date.go
similarity index 97%
rename from adapter/handlebars/helpers/date.go
rename to internal/adapter/handlebars/helpers/date.go
index 64f7779..136de89 100644
--- a/adapter/handlebars/helpers/date.go
+++ b/internal/adapter/handlebars/helpers/date.go
@@ -5,7 +5,7 @@ import (
"github.com/aymerick/raymond"
"github.com/lestrrat-go/strftime"
- "github.com/mickael-menu/zk/util"
+ "github.com/mickael-menu/zk/internal/util"
"github.com/rvflash/elapsed"
)
diff --git a/adapter/handlebars/helpers/join.go b/internal/adapter/handlebars/helpers/join.go
similarity index 100%
rename from adapter/handlebars/helpers/join.go
rename to internal/adapter/handlebars/helpers/join.go
diff --git a/adapter/handlebars/helpers/list.go b/internal/adapter/handlebars/helpers/list.go
similarity index 100%
rename from adapter/handlebars/helpers/list.go
rename to internal/adapter/handlebars/helpers/list.go
diff --git a/adapter/handlebars/helpers/prepend.go b/internal/adapter/handlebars/helpers/prepend.go
similarity index 89%
rename from adapter/handlebars/helpers/prepend.go
rename to internal/adapter/handlebars/helpers/prepend.go
index fa07418..f6d33ea 100644
--- a/adapter/handlebars/helpers/prepend.go
+++ b/internal/adapter/handlebars/helpers/prepend.go
@@ -2,8 +2,8 @@ package helpers
import (
"github.com/aymerick/raymond"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/strings"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/strings"
)
// RegisterPrepend registers a {{prepend}} template helper which prepend a
diff --git a/adapter/handlebars/helpers/shell.go b/internal/adapter/handlebars/helpers/shell.go
similarity index 88%
rename from adapter/handlebars/helpers/shell.go
rename to internal/adapter/handlebars/helpers/shell.go
index b28d6b8..f6a4ce3 100644
--- a/adapter/handlebars/helpers/shell.go
+++ b/internal/adapter/handlebars/helpers/shell.go
@@ -4,8 +4,8 @@ import (
"strings"
"github.com/aymerick/raymond"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/exec"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/exec"
)
// RegisterShell registers the {{sh}} template helper, which runs shell commands.
diff --git a/adapter/handlebars/helpers/slug.go b/internal/adapter/handlebars/helpers/slug.go
similarity index 69%
rename from adapter/handlebars/helpers/slug.go
rename to internal/adapter/handlebars/helpers/slug.go
index 38cac8c..1dd4794 100644
--- a/adapter/handlebars/helpers/slug.go
+++ b/internal/adapter/handlebars/helpers/slug.go
@@ -3,15 +3,15 @@ package helpers
import (
"github.com/aymerick/raymond"
"github.com/gosimple/slug"
- "github.com/mickael-menu/zk/util"
+ "github.com/mickael-menu/zk/internal/util"
)
-// RegisterSlug registers a {{slug}} template helper to slugify text.
+// NewSlugHelper creates a new template helper to slugify text.
//
// {{slug "This will be slugified!"}} -> this-will-be-slugified
// {{#slug}}This will be slugified!{{/slug}} -> this-will-be-slugified
-func RegisterSlug(lang string, logger util.Logger) {
- raymond.RegisterHelper("slug", func(opt interface{}) string {
+func NewSlugHelper(lang string, logger util.Logger) interface{} {
+ return func(opt interface{}) string {
switch arg := opt.(type) {
case *raymond.Options:
return slug.MakeLang(arg.Fn(), lang)
@@ -21,5 +21,5 @@ func RegisterSlug(lang string, logger util.Logger) {
logger.Printf("the {{slug}} template helper is expecting a string as argument, received: %v", opt)
return ""
}
- })
+ }
}
diff --git a/adapter/handlebars/helpers/style.go b/internal/adapter/handlebars/helpers/style.go
similarity index 59%
rename from adapter/handlebars/helpers/style.go
rename to internal/adapter/handlebars/helpers/style.go
index 08c87ad..c31831c 100644
--- a/adapter/handlebars/helpers/style.go
+++ b/internal/adapter/handlebars/helpers/style.go
@@ -4,20 +4,20 @@ import (
"strings"
"github.com/aymerick/raymond"
- "github.com/mickael-menu/zk/core/style"
- "github.com/mickael-menu/zk/util"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
)
-// RegisterStyle register the {{style}} template helpers which stylizes the
-// text input according to predefined styling rules.
+// NewStyleHelper creates a new template helper which stylizes the text input
+// according to predefined styling rules.
//
// {{style "date" created}}
// {{#style "red"}}Hello, world{{/style}}
-func RegisterStyle(styler style.Styler, logger util.Logger) {
+func NewStyleHelper(styler core.Styler, logger util.Logger) interface{} {
style := func(keys string, text string) string {
- rules := make([]style.Rule, 0)
+ rules := make([]core.Style, 0)
for _, key := range strings.Fields(keys) {
- rules = append(rules, style.Rule(key))
+ rules = append(rules, core.Style(key))
}
res, err := styler.Style(text, rules...)
if err != nil {
@@ -28,7 +28,7 @@ func RegisterStyle(styler style.Styler, logger util.Logger) {
}
}
- raymond.RegisterHelper("style", func(rules string, opt interface{}) string {
+ return func(rules string, opt interface{}) string {
switch arg := opt.(type) {
case *raymond.Options:
return style(rules, arg.Fn())
@@ -38,5 +38,5 @@ func RegisterStyle(styler style.Styler, logger util.Logger) {
logger.Printf("the {{style}} template helper is expecting a string as input, received: %v", opt)
return ""
}
- })
+ }
}
diff --git a/adapter/handlebars/fixtures/template.txt b/internal/adapter/handlebars/testdata/template.txt
similarity index 100%
rename from adapter/handlebars/fixtures/template.txt
rename to internal/adapter/handlebars/testdata/template.txt
diff --git a/adapter/handlebars/fixtures/unescape.txt b/internal/adapter/handlebars/testdata/unescape.txt
similarity index 100%
rename from adapter/handlebars/fixtures/unescape.txt
rename to internal/adapter/handlebars/testdata/unescape.txt
diff --git a/adapter/lsp/document.go b/internal/adapter/lsp/document.go
similarity index 100%
rename from adapter/lsp/document.go
rename to internal/adapter/lsp/document.go
diff --git a/internal/adapter/lsp/logger.go b/internal/adapter/lsp/logger.go
new file mode 100644
index 0000000..e3f88e1
--- /dev/null
+++ b/internal/adapter/lsp/logger.go
@@ -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)
+ }
+}
diff --git a/adapter/lsp/server.go b/internal/adapter/lsp/server.go
similarity index 64%
rename from adapter/lsp/server.go
rename to internal/adapter/lsp/server.go
index 7491003..c90875c 100644
--- a/adapter/lsp/server.go
+++ b/internal/adapter/lsp/server.go
@@ -7,13 +7,11 @@ import (
"path/filepath"
"strings"
- "github.com/mickael-menu/zk/adapter"
- "github.com/mickael-menu/zk/adapter/sqlite"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/core/zk"
- "github.com/mickael-menu/zk/util/errors"
- "github.com/mickael-menu/zk/util/opt"
- strutil "github.com/mickael-menu/zk/util/strings"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/errors"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ strutil "github.com/mickael-menu/zk/internal/util/strings"
"github.com/tliron/glsp"
protocol "github.com/tliron/glsp/protocol_3_16"
glspserv "github.com/tliron/glsp/server"
@@ -24,8 +22,10 @@ import (
// Server holds the state of the Language Server.
type Server struct {
server *glspserv.Server
- container *adapter.Container
+ notebooks *core.NotebookStore
documents map[protocol.DocumentUri]*document
+ fs core.FileStorage
+ logger util.Logger
}
// ServerOpts holds the options to create a new Server.
@@ -33,11 +33,14 @@ type ServerOpts struct {
Name string
Version string
LogFile opt.String
- Container *adapter.Container
+ Logger *util.ProxyLogger
+ Notebooks *core.NotebookStore
+ FS core.FileStorage
}
// NewServer creates a new Server instance.
func NewServer(opts ServerOpts) *Server {
+ fs := opts.FS
debug := !opts.LogFile.IsNull()
if debug {
logging.Configure(10, opts.LogFile.Value)
@@ -47,8 +50,16 @@ func NewServer(opts ServerOpts) *Server {
handler := protocol.Handler{}
server := &Server{
server: glspserv.NewServer(&handler, opts.Name, debug),
- container: opts.Container,
+ notebooks: opts.Notebooks,
documents: map[string]*document{},
+ fs: fs,
+ }
+
+ // Redirect zk's logger to GLSP's to avoid breaking the JSON-RPC protocol
+ // with unwanted output.
+ if opts.Logger != nil {
+ opts.Logger.Logger = newGlspLogger(server.server.Log)
+ server.logger = opts.Logger
}
var clientCapabilities protocol.ClientCapabilities
@@ -66,8 +77,6 @@ func NewServer(opts ServerOpts) *Server {
workspace.addFolder(*params.RootPath)
}
- server.container.OpenNotebook(workspace.folders)
-
// To see the logs with coc.nvim, run :CocCommand workspace.showOutput
// https://github.com/neoclide/coc.nvim/wiki/Debug-language-server#using-output-channel
if params.Trace != nil {
@@ -77,30 +86,19 @@ func NewServer(opts ServerOpts) *Server {
capabilities := handler.CreateServerCapabilities()
capabilities.HoverProvider = true
- zk, err := server.container.Zk()
- if err == nil {
- capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental
- capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{
- ResolveProvider: boolPtr(true),
- }
-
- triggerChars := []string{"["}
-
- // Setup tag completion trigger characters
- if zk.Config.Format.Markdown.Hashtags {
- triggerChars = append(triggerChars, "#")
- }
- if zk.Config.Format.Markdown.ColonTags {
- triggerChars = append(triggerChars, ":")
- }
+ capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental
+ capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{
+ ResolveProvider: boolPtr(true),
+ }
- capabilities.CompletionProvider = &protocol.CompletionOptions{
- TriggerCharacters: triggerChars,
- }
+ triggerChars := []string{"[", "#", ":"}
- capabilities.DefinitionProvider = boolPtr(true)
+ capabilities.CompletionProvider = &protocol.CompletionOptions{
+ TriggerCharacters: triggerChars,
}
+ capabilities.DefinitionProvider = boolPtr(true)
+
return protocol.InitializeResult{
Capabilities: capabilities,
ServerInfo: &protocol.InitializeResultServerInfo{
@@ -140,8 +138,10 @@ func NewServer(opts ServerOpts) *Server {
return nil
}
+ path := fs.Canonical(strings.TrimPrefix(params.TextDocument.URI, "file://"))
+
server.documents[params.TextDocument.URI] = &document{
- Path: strings.TrimPrefix(params.TextDocument.URI, "file://"),
+ Path: path,
Content: params.TextDocument.Text,
Log: server.server.Log,
}
@@ -170,17 +170,31 @@ func NewServer(opts ServerOpts) *Server {
handler.TextDocumentCompletion = func(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) {
triggerChar := params.Context.TriggerCharacter
-
if params.Context.TriggerKind != protocol.CompletionTriggerKindTriggerCharacter || triggerChar == nil {
return nil, nil
}
- switch *triggerChar {
- case "#", ":":
- return server.buildTagCompletionList(*triggerChar)
+ doc, ok := server.documents[params.TextDocument.URI]
+ if !ok {
+ return nil, nil
+ }
+
+ notebook, err := server.notebookOf(doc)
+ if err != nil {
+ return nil, err
+ }
+ switch *triggerChar {
+ case "#":
+ if notebook.Config.Format.Markdown.Hashtags {
+ return server.buildTagCompletionList(notebook, "#")
+ }
+ case ":":
+ if notebook.Config.Format.Markdown.ColonTags {
+ return server.buildTagCompletionList(notebook, ":")
+ }
case "[":
- return server.buildLinkCompletionList(params)
+ return server.buildLinkCompletionList(doc, notebook, params)
}
return nil, nil
@@ -192,29 +206,21 @@ func NewServer(opts ServerOpts) *Server {
return nil, nil
}
- zk, err := server.container.Zk()
- if err != nil {
+ link, err := doc.DocumentLinkAt(params.Position)
+ if link == nil || err != nil {
return nil, err
}
- db, _, err := server.container.Database(false)
+ notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
- var target string
- err = db.WithTransaction(func(tx sqlite.Transaction) error {
- finder := sqlite.NewNoteDAO(tx, server.container.Logger)
- link, err := doc.DocumentLinkAt(params.Position)
- if link == nil || err != nil {
- return err
- }
- target, err = server.targetForHref(link.Href, doc, zk.Path, finder)
- return err
- })
+ target, err := server.targetForHref(link.Href, doc, notebook)
if err != nil || target == "" || strutil.IsURL(target) {
return nil, err
}
+
target = strings.TrimPrefix(target, "file://")
contents, err := ioutil.ReadFile(target)
if err != nil {
@@ -232,41 +238,31 @@ func NewServer(opts ServerOpts) *Server {
handler.TextDocumentDocumentLink = func(context *glsp.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
- return []protocol.DocumentLink{}, nil
+ return nil, nil
}
- zk, err := server.container.Zk()
+ links, err := doc.DocumentLinks()
if err != nil {
return nil, err
}
- db, _, err := server.container.Database(false)
+ notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
- var documentLinks []protocol.DocumentLink
- err = db.WithTransaction(func(tx sqlite.Transaction) error {
- finder := sqlite.NewNoteDAO(tx, server.container.Logger)
- links, err := doc.DocumentLinks()
- if err != nil {
- return err
+ documentLinks := []protocol.DocumentLink{}
+ for _, link := range links {
+ target, err := server.targetForHref(link.Href, doc, notebook)
+ if target == "" || err != nil {
+ continue
}
- for _, link := range links {
- target, err := server.targetForHref(link.Href, doc, zk.Path, finder)
- if target == "" || err != nil {
- continue
- }
-
- documentLinks = append(documentLinks, protocol.DocumentLink{
- Range: link.Range,
- Target: &target,
- })
- }
-
- return nil
- })
+ documentLinks = append(documentLinks, protocol.DocumentLink{
+ Range: link.Range,
+ Target: &target,
+ })
+ }
return documentLinks, err
}
@@ -274,30 +270,20 @@ func NewServer(opts ServerOpts) *Server {
handler.TextDocumentDefinition = func(context *glsp.Context, params *protocol.DefinitionParams) (interface{}, error) {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
- return []protocol.DocumentLink{}, nil
+ return nil, nil
}
- zk, err := server.container.Zk()
- if err != nil {
+ link, err := doc.DocumentLinkAt(params.Position)
+ if link == nil || err != nil {
return nil, err
}
- db, _, err := server.container.Database(false)
+ notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
- var link *documentLink
- var target string
- err = db.WithTransaction(func(tx sqlite.Transaction) error {
- finder := sqlite.NewNoteDAO(tx, server.container.Logger)
- link, err = doc.DocumentLinkAt(params.Position)
- if link == nil || err != nil {
- return err
- }
- target, err = server.targetForHref(link.Href, doc, zk.Path, finder)
- return err
- })
+ target, err := server.targetForHref(link.Href, doc, notebook)
if link == nil || target == "" || err != nil {
return nil, err
}
@@ -319,25 +305,29 @@ func NewServer(opts ServerOpts) *Server {
return server
}
+func (s *Server) notebookOf(doc *document) (*core.Notebook, error) {
+ return s.notebooks.Open(doc.Path)
+}
+
// targetForHref returns the LSP documentUri for the note at the given HREF.
-func (s *Server) targetForHref(href string, doc *document, basePath string, finder note.Finder) (string, error) {
+func (s *Server) targetForHref(href string, doc *document, notebook *core.Notebook) (string, error) {
if strutil.IsURL(href) {
return href, nil
} else {
path := filepath.Clean(filepath.Join(filepath.Dir(doc.Path), href))
- path, err := filepath.Rel(basePath, path)
+ path, err := filepath.Rel(notebook.Path, path)
if err != nil {
return "", errors.Wrapf(err, "failed to resolve href: %s", href)
}
- note, err := finder.FindByHref(path)
+ note, err := notebook.FindByHref(path)
if err != nil {
- s.server.Log.Errorf("findByHref(%s): %s", href, err.Error())
+ s.logger.Printf("findByHref(%s): %s", href, err.Error())
return "", err
}
if note == nil {
return "", nil
}
- return "file://" + filepath.Join(basePath, note.Path), nil
+ return "file://" + filepath.Join(notebook.Path, note.Path), nil
}
}
@@ -346,21 +336,8 @@ func (s *Server) Run() error {
return errors.Wrap(s.server.RunStdio(), "lsp")
}
-func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.CompletionItem, error) {
- zk, err := s.container.Zk()
- if err != nil {
- return nil, err
- }
- db, _, err := s.container.Database(false)
- if err != nil {
- return nil, err
- }
-
- var tags []note.Collection
- err = db.WithTransaction(func(tx sqlite.Transaction) error {
- tags, err = sqlite.NewCollectionDAO(tx, s.container.Logger).FindAll(note.CollectionKindTag)
- return err
- })
+func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar string) ([]protocol.CompletionItem, error) {
+ tags, err := notebook.FindCollections(core.CollectionKindTag)
if err != nil {
return nil, err
}
@@ -369,7 +346,7 @@ func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.Completi
for _, tag := range tags {
items = append(items, protocol.CompletionItem{
Label: tag.Name,
- InsertText: s.buildInsertForTag(tag.Name, triggerChar, zk.Config),
+ InsertText: s.buildInsertForTag(tag.Name, triggerChar, notebook.Config),
Detail: stringPtr(fmt.Sprintf("%d %s", tag.NoteCount, strutil.Pluralize("note", tag.NoteCount))),
})
}
@@ -377,7 +354,7 @@ func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.Completi
return items, nil
}
-func (s *Server) buildInsertForTag(name string, triggerChar string, config zk.Config) *string {
+func (s *Server) buildInsertForTag(name string, triggerChar string, config core.Config) *string {
switch triggerChar {
case ":":
name += ":"
@@ -393,27 +370,8 @@ func (s *Server) buildInsertForTag(name string, triggerChar string, config zk.Co
return &name
}
-func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
- zk, err := s.container.Zk()
- if err != nil {
- return nil, err
- }
- doc, ok := s.documents[params.TextDocument.URI]
- if !ok {
- return nil, nil
- }
-
- db, _, err := s.container.Database(false)
- if err != nil {
- return nil, err
- }
-
- var notes []note.Match
- err = db.WithTransaction(func(tx sqlite.Transaction) error {
- finder := sqlite.NewNoteDAO(tx, s.container.Logger)
- notes, err = finder.Find(note.FinderOpts{})
- return err
- })
+func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
+ notes, err := notebook.FindNotes(core.NoteFindOpts{})
if err != nil {
return nil, err
}
@@ -422,7 +380,7 @@ func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]p
for _, note := range notes {
items = append(items, protocol.CompletionItem{
Label: note.Title,
- TextEdit: s.buildTextEditForLink(zk, note, doc, params.Position),
+ TextEdit: s.buildTextEditForLink(notebook, note, doc, params.Position),
Documentation: protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: note.RawContent,
@@ -433,11 +391,12 @@ func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]p
return items, nil
}
-func (s *Server) buildTextEditForLink(zk *zk.Zk, note note.Match, document *document, pos protocol.Position) interface{} {
+func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position) interface{} {
isWikiLink := (document.LookBehind(pos, 2) == "[[")
var text string
- path := filepath.Join(zk.Path, note.Path)
+ path := filepath.Join(notebook.Path, note.Path)
+ path = s.fs.Canonical(path)
path, err := filepath.Rel(filepath.Dir(document.Path), path)
if err != nil {
path = note.Path
diff --git a/adapter/lsp/workspace.go b/internal/adapter/lsp/workspace.go
similarity index 100%
rename from adapter/lsp/workspace.go
rename to internal/adapter/lsp/workspace.go
diff --git a/adapter/markdown/extensions/tag.go b/internal/adapter/markdown/extensions/tag.go
similarity index 100%
rename from adapter/markdown/extensions/tag.go
rename to internal/adapter/markdown/extensions/tag.go
diff --git a/adapter/markdown/extensions/wikilink.go b/internal/adapter/markdown/extensions/wikilink.go
similarity index 94%
rename from adapter/markdown/extensions/wikilink.go
rename to internal/adapter/markdown/extensions/wikilink.go
index 8db0738..4198fa2 100644
--- a/adapter/markdown/extensions/wikilink.go
+++ b/internal/adapter/markdown/extensions/wikilink.go
@@ -3,7 +3,7 @@ package extensions
import (
"strings"
- "github.com/mickael-menu/zk/core/note"
+ "github.com/mickael-menu/zk/internal/core"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
@@ -38,7 +38,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
var (
href string
label string
- rel note.LinkRelation
+ rel core.LinkRelation
)
var (
@@ -65,7 +65,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
if closed {
// Supports trailing hash syntax for Neuron's Folgezettel, e.g. [[id]]#
if char == '#' {
- rel = note.LinkRelationDown
+ rel = core.LinkRelationDown
}
break
}
@@ -74,7 +74,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
switch char {
// Supports leading hash syntax for Neuron's Folgezettel, e.g. #[[id]]
case '#':
- rel = note.LinkRelationUp
+ rel = core.LinkRelationUp
continue
case '[':
openerCharCount += 1
@@ -104,7 +104,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context)
closed = true
// Neuron's legacy [[[Folgezettel]]].
if closerCharCount == 3 {
- rel = note.LinkRelationDown
+ rel = core.LinkRelationDown
}
}
continue
diff --git a/adapter/markdown/markdown.go b/internal/adapter/markdown/markdown.go
similarity index 91%
rename from adapter/markdown/markdown.go
rename to internal/adapter/markdown/markdown.go
index ce50070..aae7c0c 100644
--- a/adapter/markdown/markdown.go
+++ b/internal/adapter/markdown/markdown.go
@@ -6,11 +6,11 @@ import (
"regexp"
"strings"
- "github.com/mickael-menu/zk/adapter/markdown/extensions"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/util/opt"
- strutil "github.com/mickael-menu/zk/util/strings"
- "github.com/mickael-menu/zk/util/yaml"
+ "github.com/mickael-menu/zk/internal/adapter/markdown/extensions"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ strutil "github.com/mickael-menu/zk/internal/util/strings"
+ "github.com/mickael-menu/zk/internal/util/yaml"
"github.com/mvdan/xurls"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
@@ -60,9 +60,9 @@ func NewParser(options ParserOpts) *Parser {
}
}
-// Parse implements note.Parse.
-func (p *Parser) Parse(source string) (*note.Content, error) {
- bytes := []byte(source)
+// Parse implements core.NoteParser.
+func (p *Parser) Parse(content string) (*core.ParsedNote, error) {
+ bytes := []byte(content)
context := parser.NewContext()
root := p.md.Parser().Parse(
@@ -91,7 +91,7 @@ func (p *Parser) Parse(source string) (*note.Content, error) {
return nil, err
}
- return ¬e.Content{
+ return &core.ParsedNote{
Title: title,
Body: body,
Lead: parseLead(body),
@@ -208,8 +208,8 @@ func parseTags(frontmatter frontmatter, root ast.Node, source []byte) ([]string,
}
// parseLinks extracts outbound links from the note.
-func parseLinks(root ast.Node, source []byte) ([]note.Link, error) {
- links := make([]note.Link, 0)
+func parseLinks(root ast.Node, source []byte) ([]core.Link, error) {
+ links := make([]core.Link, 0)
err := ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
@@ -218,11 +218,11 @@ func parseLinks(root ast.Node, source []byte) ([]note.Link, error) {
href := string(link.Destination)
if href != "" {
snippet, snStart, snEnd := extractLines(n, source)
- links = append(links, note.Link{
+ links = append(links, core.Link{
Title: string(link.Text(source)),
Href: href,
- Rels: strings.Fields(string(link.Title)),
- External: strutil.IsURL(href),
+ Rels: core.LinkRels(strings.Fields(string(link.Title))...),
+ IsExternal: strutil.IsURL(href),
Snippet: snippet,
SnippetStart: snStart,
SnippetEnd: snEnd,
@@ -232,11 +232,11 @@ func parseLinks(root ast.Node, source []byte) ([]note.Link, error) {
case *ast.AutoLink:
if href := string(link.URL(source)); href != "" && link.AutoLinkType == ast.AutoLinkURL {
snippet, snStart, snEnd := extractLines(n, source)
- links = append(links, note.Link{
+ links = append(links, core.Link{
Title: string(link.Label(source)),
Href: href,
- Rels: []string{},
- External: true,
+ Rels: []core.LinkRelation{},
+ IsExternal: true,
Snippet: snippet,
SnippetStart: snStart,
SnippetEnd: snEnd,
diff --git a/adapter/markdown/markdown_test.go b/internal/adapter/markdown/markdown_test.go
similarity index 89%
rename from adapter/markdown/markdown_test.go
rename to internal/adapter/markdown/markdown_test.go
index 96133a1..fa0f5fe 100644
--- a/adapter/markdown/markdown_test.go
+++ b/internal/adapter/markdown/markdown_test.go
@@ -3,9 +3,9 @@ package markdown
import (
"testing"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/util/opt"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestParseTitle(t *testing.T) {
@@ -346,13 +346,13 @@ Tags: [tag1, "#tag1", tag2]
}
func TestParseLinks(t *testing.T) {
- test := func(source string, links []note.Link) {
+ test := func(source string, links []core.Link) {
content := parse(t, source)
assert.Equal(t, content.Links, links)
}
- test("", []note.Link{})
- test("No links around here", []note.Link{})
+ test("", []core.Link{})
+ test("No links around here", []core.Link{})
test(`
# Heading with a [link](heading)
@@ -375,51 +375,51 @@ A #[[leading hash]] is used for #uplinks.
Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]
[External links](http://example.com) are marked [as such](ftp://domain).
-`, []note.Link{
+`, []core.Link{
{
Title: "link",
Href: "heading",
- Rels: []string{},
- External: false,
+ Rels: []core.LinkRelation{},
+ IsExternal: false,
Snippet: "Heading with a [link](heading)",
SnippetStart: 3,
SnippetEnd: 33,
},
{
- Title: "multiple links",
- Href: "stripped-formatting",
- Rels: []string{},
- External: false,
+ Title: "multiple links",
+ Href: "stripped-formatting",
+ Rels: []core.LinkRelation{},
+ IsExternal: false,
Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other).
A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`,
SnippetStart: 35,
SnippetEnd: 222,
},
{
- Title: "relative",
- Href: "../other",
- Rels: []string{},
- External: false,
+ Title: "relative",
+ Href: "../other",
+ Rels: []core.LinkRelation{},
+ IsExternal: false,
Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other).
A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`,
SnippetStart: 35,
SnippetEnd: 222,
},
{
- Title: "one relation",
- Href: "one",
- Rels: []string{"rel-1"},
- External: false,
+ Title: "one relation",
+ Href: "one",
+ Rels: core.LinkRels("rel-1"),
+ IsExternal: false,
Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other).
A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`,
SnippetStart: 35,
SnippetEnd: 222,
},
{
- Title: "several relations",
- Href: "several",
- Rels: []string{"rel-1", "rel-2"},
- External: false,
+ Title: "several relations",
+ Href: "several",
+ Rels: core.LinkRels("rel-1", "rel-2"),
+ IsExternal: false,
Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other).
A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`,
SnippetStart: 35,
@@ -428,8 +428,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "https://inline-link.com",
Href: "https://inline-link.com",
- External: true,
- Rels: []string{},
+ IsExternal: true,
+ Rels: []core.LinkRelation{},
Snippet: "An https://inline-link.com and http://another-inline-link.com.",
SnippetStart: 224,
SnippetEnd: 286,
@@ -437,8 +437,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "http://another-inline-link.com",
Href: "http://another-inline-link.com",
- External: true,
- Rels: []string{},
+ IsExternal: true,
+ Rels: []core.LinkRelation{},
Snippet: "An https://inline-link.com and http://another-inline-link.com.",
SnippetStart: 224,
SnippetEnd: 286,
@@ -446,8 +446,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Wiki link",
Href: "Wiki link",
- External: false,
- Rels: []string{},
+ IsExternal: false,
+ Rels: []core.LinkRelation{},
Snippet: "A [[Wiki link]] is surrounded by [[2-brackets | two brackets]].",
SnippetStart: 288,
SnippetEnd: 351,
@@ -455,8 +455,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "two brackets",
Href: "2-brackets",
- External: false,
- Rels: []string{},
+ IsExternal: false,
+ Rels: []core.LinkRelation{},
Snippet: "A [[Wiki link]] is surrounded by [[2-brackets | two brackets]].",
SnippetStart: 288,
SnippetEnd: 351,
@@ -464,8 +464,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: `esca]]ped [chara\cters`,
Href: `esca]]ped [chara\cters`,
- External: false,
- Rels: []string{},
+ IsExternal: false,
+ Rels: []core.LinkRelation{},
Snippet: `It can contain [[esca]\]ped \[chara\\cters]].`,
SnippetStart: 353,
SnippetEnd: 398,
@@ -473,8 +473,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Folgezettel link",
Href: "Folgezettel link",
- External: false,
- Rels: []string{"down"},
+ IsExternal: false,
+ Rels: core.LinkRels("down"),
Snippet: "A [[[Folgezettel link]]] is surrounded by three brackets.",
SnippetStart: 400,
SnippetEnd: 457,
@@ -482,8 +482,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "trailing hash",
Href: "trailing hash",
- External: false,
- Rels: []string{"down"},
+ IsExternal: false,
+ Rels: core.LinkRels("down"),
Snippet: "Neuron also supports a [[trailing hash]]# for Folgezettel links.",
SnippetStart: 459,
SnippetEnd: 523,
@@ -491,8 +491,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "leading hash",
Href: "leading hash",
- External: false,
- Rels: []string{"up"},
+ IsExternal: false,
+ Rels: core.LinkRels("up"),
Snippet: "A #[[leading hash]] is used for #uplinks.",
SnippetStart: 525,
SnippetEnd: 566,
@@ -500,8 +500,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Trailing link",
Href: "trailing",
- External: false,
- Rels: []string{"down"},
+ IsExternal: false,
+ Rels: core.LinkRels("down"),
Snippet: "Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]",
SnippetStart: 568,
SnippetEnd: 650,
@@ -509,8 +509,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "Leading link",
Href: "leading",
- External: false,
- Rels: []string{"up"},
+ IsExternal: false,
+ Rels: core.LinkRels("up"),
Snippet: "Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]",
SnippetStart: 568,
SnippetEnd: 650,
@@ -518,8 +518,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "External links",
Href: "http://example.com",
- Rels: []string{},
- External: true,
+ Rels: []core.LinkRelation{},
+ IsExternal: true,
Snippet: `[External links](http://example.com) are marked [as such](ftp://domain).`,
SnippetStart: 652,
SnippetEnd: 724,
@@ -527,8 +527,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel-
{
Title: "as such",
Href: "ftp://domain",
- Rels: []string{},
- External: true,
+ Rels: []core.LinkRelation{},
+ IsExternal: true,
Snippet: `[External links](http://example.com) are marked [as such](ftp://domain).`,
SnippetStart: 652,
SnippetEnd: 724,
@@ -564,7 +564,7 @@ Paragraph
})
}
-func parse(t *testing.T, source string) note.Content {
+func parse(t *testing.T, source string) core.ParsedNote {
return parseWithOptions(t, source, ParserOpts{
HashtagEnabled: true,
MultiWordTagEnabled: true,
@@ -572,7 +572,7 @@ func parse(t *testing.T, source string) note.Content {
})
}
-func parseWithOptions(t *testing.T, source string, options ParserOpts) note.Content {
+func parseWithOptions(t *testing.T, source string, options ParserOpts) core.ParsedNote {
content, err := NewParser(options).Parse(source)
assert.Nil(t, err)
return *content
diff --git a/adapter/sqlite/collection_dao.go b/internal/adapter/sqlite/collection_dao.go
similarity index 75%
rename from adapter/sqlite/collection_dao.go
rename to internal/adapter/sqlite/collection_dao.go
index 8866046..3eda465 100644
--- a/adapter/sqlite/collection_dao.go
+++ b/internal/adapter/sqlite/collection_dao.go
@@ -4,10 +4,9 @@ import (
"database/sql"
"fmt"
- "github.com/mickael-menu/zk/core"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/errors"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/errors"
)
// CollectionDAO persists collections (e.g. tags) in the SQLite database.
@@ -47,7 +46,7 @@ func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO {
findAllCollectionsStmt: tx.PrepareLazy(`
SELECT c.name, COUNT(nc.id) as count
FROM collections c
- LEFT JOIN notes_collections nc ON nc.collection_id = c.id
+ INNER JOIN notes_collections nc ON nc.collection_id = c.id
WHERE kind = ?
GROUP BY c.id
ORDER BY c.name
@@ -75,7 +74,7 @@ func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO {
// FindOrCreate returns the ID of the collection with given kind and name.
// Creates the collection if it does not already exist.
-func (d *CollectionDAO) FindOrCreate(kind note.CollectionKind, name string) (core.CollectionId, error) {
+func (d *CollectionDAO) FindOrCreate(kind core.CollectionKind, name string) (core.CollectionID, error) {
id, err := d.findCollection(kind, name)
switch {
@@ -88,14 +87,14 @@ func (d *CollectionDAO) FindOrCreate(kind note.CollectionKind, name string) (cor
}
}
-func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, error) {
+func (d *CollectionDAO) FindAll(kind core.CollectionKind) ([]core.Collection, error) {
rows, err := d.findAllCollectionsStmt.Query(kind)
if err != nil {
- return []note.Collection{}, err
+ return []core.Collection{}, err
}
defer rows.Close()
- collections := []note.Collection{}
+ collections := []core.Collection{}
for rows.Next() {
var name string
@@ -105,7 +104,7 @@ func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, er
return collections, err
}
- collections = append(collections, note.Collection{
+ collections = append(collections, core.Collection{
Kind: kind,
Name: name,
NoteCount: count,
@@ -115,12 +114,12 @@ func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, er
return collections, nil
}
-func (d *CollectionDAO) findCollection(kind note.CollectionKind, name string) (core.CollectionId, error) {
+func (d *CollectionDAO) findCollection(kind core.CollectionKind, name string) (core.CollectionID, error) {
wrap := errors.Wrapperf("failed to get %s named %s", kind, name)
row, err := d.findCollectionStmt.QueryRow(kind, name)
if err != nil {
- return core.CollectionId(0), wrap(err)
+ return 0, wrap(err)
}
var id sql.NullInt64
@@ -128,15 +127,15 @@ func (d *CollectionDAO) findCollection(kind note.CollectionKind, name string) (c
switch {
case err == sql.ErrNoRows:
- return core.CollectionId(0), nil
+ return 0, nil
case err != nil:
- return core.CollectionId(0), wrap(err)
+ return 0, wrap(err)
default:
- return core.CollectionId(id.Int64), nil
+ return core.CollectionID(id.Int64), nil
}
}
-func (d *CollectionDAO) create(kind note.CollectionKind, name string) (core.CollectionId, error) {
+func (d *CollectionDAO) create(kind core.CollectionKind, name string) (core.CollectionID, error) {
wrap := errors.Wrapperf("failed to create new %s named %s", kind, name)
res, err := d.createCollectionStmt.Exec(kind, name)
@@ -149,12 +148,12 @@ func (d *CollectionDAO) create(kind note.CollectionKind, name string) (core.Coll
return 0, wrap(err)
}
- return core.CollectionId(id), nil
+ return core.CollectionID(id), nil
}
// Associate creates a new association between a note and a collection, if it
// does not already exist.
-func (d *CollectionDAO) Associate(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) {
+func (d *CollectionDAO) Associate(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) {
wrap := errors.Wrapperf("failed to associate note %d to collection %d", noteId, collectionId)
id, err := d.findAssociation(noteId, collectionId)
@@ -170,7 +169,7 @@ func (d *CollectionDAO) Associate(noteId core.NoteId, collectionId core.Collecti
}
}
-func (d *CollectionDAO) findAssociation(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) {
+func (d *CollectionDAO) findAssociation(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) {
if !noteId.IsValid() || !collectionId.IsValid() {
return 0, fmt.Errorf("Note ID (%d) or collection ID (%d) not valid", noteId, collectionId)
}
@@ -189,11 +188,11 @@ func (d *CollectionDAO) findAssociation(noteId core.NoteId, collectionId core.Co
case err != nil:
return 0, err
default:
- return core.NoteCollectionId(id.Int64), nil
+ return core.NoteCollectionID(id.Int64), nil
}
}
-func (d *CollectionDAO) createAssociation(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) {
+func (d *CollectionDAO) createAssociation(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) {
if !noteId.IsValid() || !collectionId.IsValid() {
return 0, fmt.Errorf("Note ID (%d) or collection ID (%d) not valid", noteId, collectionId)
}
@@ -208,11 +207,11 @@ func (d *CollectionDAO) createAssociation(noteId core.NoteId, collectionId core.
return 0, err
}
- return core.NoteCollectionId(id), nil
+ return core.NoteCollectionID(id), nil
}
// RemoveAssociations deletes all associations with the given note.
-func (d *CollectionDAO) RemoveAssociations(noteId core.NoteId) error {
+func (d *CollectionDAO) RemoveAssociations(noteId core.NoteID) error {
if !noteId.IsValid() {
return fmt.Errorf("Note ID (%d) not valid", noteId)
}
diff --git a/adapter/sqlite/collection_dao_test.go b/internal/adapter/sqlite/collection_dao_test.go
similarity index 69%
rename from adapter/sqlite/collection_dao_test.go
rename to internal/adapter/sqlite/collection_dao_test.go
index 4d161af..76d4317 100644
--- a/adapter/sqlite/collection_dao_test.go
+++ b/internal/adapter/sqlite/collection_dao_test.go
@@ -3,10 +3,9 @@ package sqlite
import (
"testing"
- "github.com/mickael-menu/zk/core"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestCollectionDAOFindOrCreate(t *testing.T) {
@@ -14,22 +13,22 @@ func TestCollectionDAOFindOrCreate(t *testing.T) {
// Finds existing ones
id, err := dao.FindOrCreate("tag", "adventure")
assert.Nil(t, err)
- assert.Equal(t, id, core.CollectionId(2))
+ assert.Equal(t, id, core.CollectionID(2))
id, err = dao.FindOrCreate("genre", "fiction")
assert.Nil(t, err)
- assert.Equal(t, id, core.CollectionId(3))
+ assert.Equal(t, id, core.CollectionID(3))
// The name is case sensitive
id, err = dao.FindOrCreate("tag", "Adventure")
assert.Nil(t, err)
- assert.NotEqual(t, id, core.CollectionId(2))
+ assert.NotEqual(t, id, core.CollectionID(2))
// Creates when not found
sql := "SELECT id FROM collections WHERE kind = ? AND name = ?"
- assertNotExist(t, tx, sql, "unknown", "created")
+ assertNotExistTx(t, tx, sql, "unknown", "created")
_, err = dao.FindOrCreate("unknown", "created")
assert.Nil(t, err)
- assertExist(t, tx, sql, "unknown", "created")
+ assertExistTx(t, tx, sql, "unknown", "created")
})
}
@@ -43,7 +42,7 @@ func TestCollectionDaoFindAll(t *testing.T) {
// Finds existing
cs, err = dao.FindAll("tag")
assert.Nil(t, err)
- assert.Equal(t, cs, []note.Collection{
+ assert.Equal(t, cs, []core.Collection{
{Kind: "tag", Name: "adventure", NoteCount: 2},
{Kind: "tag", Name: "fantasy", NoteCount: 1},
{Kind: "tag", Name: "fiction", NoteCount: 1},
@@ -55,32 +54,32 @@ func TestCollectionDaoFindAll(t *testing.T) {
func TestCollectionDAOAssociate(t *testing.T) {
testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) {
// Returns existing association
- id, err := dao.Associate(1, 2)
+ id, err := dao.Associate(core.NoteID(1), core.CollectionID(2))
assert.Nil(t, err)
- assert.Equal(t, id, core.NoteCollectionId(2))
+ assert.Equal(t, id, core.NoteCollectionID(2))
// Creates a new association if missing
- noteId := core.NoteId(5)
- collectionId := core.CollectionId(3)
+ noteId := core.NoteID(5)
+ collectionId := core.CollectionID(3)
sql := "SELECT id FROM notes_collections WHERE note_id = ? AND collection_id = ?"
- assertNotExist(t, tx, sql, noteId, collectionId)
+ assertNotExistTx(t, tx, sql, noteId, collectionId)
_, err = dao.Associate(noteId, collectionId)
assert.Nil(t, err)
- assertExist(t, tx, sql, noteId, collectionId)
+ assertExistTx(t, tx, sql, noteId, collectionId)
})
}
func TestCollectionDAORemoveAssociations(t *testing.T) {
testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) {
- noteId := core.NoteId(1)
+ noteId := core.NoteID(1)
sql := "SELECT id FROM notes_collections WHERE note_id = ?"
- assertExist(t, tx, sql, noteId)
+ assertExistTx(t, tx, sql, noteId)
err := dao.RemoveAssociations(noteId)
assert.Nil(t, err)
- assertNotExist(t, tx, sql, noteId)
+ assertNotExistTx(t, tx, sql, noteId)
// Removes associations of note without any.
- err = dao.RemoveAssociations(999)
+ err = dao.RemoveAssociations(core.NoteID(999))
assert.Nil(t, err)
})
}
diff --git a/adapter/sqlite/db.go b/internal/adapter/sqlite/db.go
similarity index 83%
rename from adapter/sqlite/db.go
rename to internal/adapter/sqlite/db.go
index 06ec8b8..688eae9 100644
--- a/adapter/sqlite/db.go
+++ b/internal/adapter/sqlite/db.go
@@ -4,8 +4,8 @@ import (
"database/sql"
sqlite "github.com/mattn/go-sqlite3"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/util/errors"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util/errors"
)
func init() {
@@ -38,19 +38,26 @@ func OpenInMemory() (*DB, error) {
func open(uri string) (*DB, error) {
wrap := errors.Wrapper("failed to open the database")
- db, err := sql.Open("sqlite3_custom", uri)
+ nativeDB, err := sql.Open("sqlite3_custom", uri)
if err != nil {
return nil, wrap(err)
}
// Make sure that CASCADE statements are properly applied by enabling
// foreign keys.
- _, err = db.Exec("PRAGMA foreign_keys = ON")
+ _, err = nativeDB.Exec("PRAGMA foreign_keys = ON")
if err != nil {
return nil, wrap(err)
}
- return &DB{db}, nil
+ db := &DB{nativeDB}
+
+ err = db.migrate()
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to migrate the database")
+ }
+
+ return db, nil
}
// Close terminates the connections to the SQLite database.
@@ -59,15 +66,17 @@ func (db *DB) Close() error {
return errors.Wrap(err, "failed to close the database")
}
-// Migrate upgrades the SQL schema of the database.
-func (db *DB) Migrate() (needsReindexing bool, err error) {
- err = db.WithTransaction(func(tx Transaction) error {
+// migrate upgrades the SQL schema of the database.
+func (db *DB) migrate() error {
+ err := db.WithTransaction(func(tx Transaction) error {
var version int
err := tx.QueryRow("PRAGMA user_version").Scan(&version)
if err != nil {
return err
}
+ needsReindexing := false
+
if version <= 0 {
err = tx.ExecStmts([]string{
// Notes
@@ -123,7 +132,6 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
END`,
`PRAGMA user_version = 1`,
})
-
if err != nil {
return err
}
@@ -155,12 +163,11 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
SELECT n.*, GROUP_CONCAT(c.name, '` + "\x01" + `') AS tags
FROM notes n
LEFT JOIN notes_collections nc ON nc.note_id = n.id
- LEFT JOIN collections c ON nc.collection_id = c.id AND c.kind = '` + string(note.CollectionKindTag) + `'
+ LEFT JOIN collections c ON nc.collection_id = c.id AND c.kind = '` + string(core.CollectionKindTag) + `'
GROUP BY n.id`,
`PRAGMA user_version = 2`,
})
-
if err != nil {
return err
}
@@ -177,7 +184,6 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
`PRAGMA user_version = 3`,
})
-
if err != nil {
return err
}
@@ -185,9 +191,30 @@ func (db *DB) Migrate() (needsReindexing bool, err error) {
needsReindexing = true
}
+ if version <= 3 {
+ err = tx.ExecStmts([]string{
+ // Metadata
+ `CREATE TABLE IF NOT EXISTS metadata (
+ key TEXT PRIMARY KEY NOT NULL,
+ value TEXT NO NULL
+ )`,
+ })
+ if err != nil {
+ return err
+ }
+ }
+
+ if needsReindexing {
+ metadata := NewMetadataDAO(tx)
+ // During the next indexing, all notes will be reindexed.
+ err = metadata.Set(reindexingRequiredKey, "true")
+ if err != nil {
+ return err
+ }
+ }
+
return nil
})
- err = errors.Wrap(err, "database migration failed")
- return
+ return errors.Wrap(err, "database migration failed")
}
diff --git a/adapter/sqlite/db_test.go b/internal/adapter/sqlite/db_test.go
similarity index 77%
rename from adapter/sqlite/db_test.go
rename to internal/adapter/sqlite/db_test.go
index f19ae95..8f806e1 100644
--- a/adapter/sqlite/db_test.go
+++ b/internal/adapter/sqlite/db_test.go
@@ -3,8 +3,8 @@ package sqlite
import (
"testing"
- "github.com/mickael-menu/zk/util/fixtures"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/fixtures"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestOpen(t *testing.T) {
@@ -23,12 +23,6 @@ func TestMigrateFrom0(t *testing.T) {
db, err := OpenInMemory()
assert.Nil(t, err)
- _, err = db.Migrate()
- assert.Nil(t, err)
- // Should be able to migrate twice in a row
- _, err = db.Migrate()
- assert.Nil(t, err)
-
err = db.WithTransaction(func(tx Transaction) error {
var version int
err := tx.QueryRow("PRAGMA user_version").Scan(&version)
diff --git a/internal/adapter/sqlite/metadata_dao.go b/internal/adapter/sqlite/metadata_dao.go
new file mode 100644
index 0000000..787e751
--- /dev/null
+++ b/internal/adapter/sqlite/metadata_dao.go
@@ -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
+}
diff --git a/internal/adapter/sqlite/metadata_dao_test.go b/internal/adapter/sqlite/metadata_dao_test.go
new file mode 100644
index 0000000..ef0db73
--- /dev/null
+++ b/internal/adapter/sqlite/metadata_dao_test.go
@@ -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))
+ })
+}
diff --git a/adapter/sqlite/note_dao.go b/internal/adapter/sqlite/note_dao.go
similarity index 83%
rename from adapter/sqlite/note_dao.go
rename to internal/adapter/sqlite/note_dao.go
index 403dbdc..a8a378b 100644
--- a/adapter/sqlite/note_dao.go
+++ b/internal/adapter/sqlite/note_dao.go
@@ -9,19 +9,17 @@ import (
"strings"
"time"
- "github.com/mickael-menu/zk/core"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/errors"
- "github.com/mickael-menu/zk/util/fts5"
- "github.com/mickael-menu/zk/util/icu"
- "github.com/mickael-menu/zk/util/opt"
- "github.com/mickael-menu/zk/util/paths"
- strutil "github.com/mickael-menu/zk/util/strings"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/errors"
+ "github.com/mickael-menu/zk/internal/util/fts5"
+ "github.com/mickael-menu/zk/internal/util/icu"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ "github.com/mickael-menu/zk/internal/util/paths"
+ strutil "github.com/mickael-menu/zk/internal/util/strings"
)
// NoteDAO persists notes in the SQLite database.
-// It implements the core port note.Finder.
type NoteDAO struct {
tx Transaction
logger util.Logger
@@ -151,7 +149,7 @@ func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) {
}
// Add inserts a new note to the index.
-func (d *NoteDAO) Add(note note.Metadata) (core.NoteId, error) {
+func (d *NoteDAO) Add(note core.Note) (core.NoteID, error) {
// For sortable_path, we replace in path / by the shortest non printable
// character available to make it sortable. Without this, sorting by the
// path would be a lexicographical sort instead of being the same order
@@ -172,16 +170,16 @@ func (d *NoteDAO) Add(note note.Metadata) (core.NoteId, error) {
lastId, err := res.LastInsertId()
if err != nil {
- return core.NoteId(0), err
+ return 0, err
}
- id := core.NoteId(lastId)
+ id := core.NoteID(lastId)
err = d.addLinks(id, note)
return id, err
}
// Update modifies an existing note.
-func (d *NoteDAO) Update(note note.Metadata) (core.NoteId, error) {
+func (d *NoteDAO) Update(note core.Note) (core.NoteID, error) {
id, err := d.findIdByPath(note.Path)
if err != nil {
return 0, err
@@ -208,7 +206,7 @@ func (d *NoteDAO) Update(note note.Metadata) (core.NoteId, error) {
return id, err
}
-func (d *NoteDAO) metadataToJSON(note note.Metadata) string {
+func (d *NoteDAO) metadataToJSON(note core.Note) string {
json, err := json.Marshal(note.Metadata)
if err != nil {
// Failure to serialize the metadata to JSON should not prevent the
@@ -220,14 +218,14 @@ func (d *NoteDAO) metadataToJSON(note note.Metadata) string {
}
// addLinks inserts all the outbound links of the given note.
-func (d *NoteDAO) addLinks(id core.NoteId, note note.Metadata) error {
+func (d *NoteDAO) addLinks(id core.NoteID, note core.Note) error {
for _, link := range note.Links {
targetId, err := d.findIdByPathPrefix(link.Href)
if err != nil {
return err
}
- _, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.External, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd)
+ _, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.IsExternal, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd)
if err != nil {
return err
}
@@ -239,12 +237,16 @@ func (d *NoteDAO) addLinks(id core.NoteId, note note.Metadata) error {
// joinLinkRels will concatenate a list of rels into a SQLite ready string.
// Each rel is delimited by \x01 for easy matching in queries.
-func joinLinkRels(rels []string) string {
+func joinLinkRels(rels []core.LinkRelation) string {
if len(rels) == 0 {
return ""
}
delimiter := "\x01"
- return delimiter + strings.Join(rels, delimiter) + delimiter
+ res := delimiter
+ for _, rel := range rels {
+ res += string(rel) + delimiter
+ }
+ return res
}
// Remove deletes the note with the given path from the index.
@@ -261,16 +263,16 @@ func (d *NoteDAO) Remove(path string) error {
return err
}
-func (d *NoteDAO) findIdByPath(path string) (core.NoteId, error) {
+func (d *NoteDAO) findIdByPath(path string) (core.NoteID, error) {
row, err := d.findIdByPathStmt.QueryRow(path)
if err != nil {
- return core.NoteId(0), err
+ return core.NoteID(0), err
}
return idForRow(row)
}
-func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteId, error) {
- ids := make([]core.NoteId, 0)
+func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteID, error) {
+ ids := make([]core.NoteID, 0)
for _, path := range paths {
id, err := d.findIdByPathPrefix(path)
if err != nil {
@@ -287,71 +289,107 @@ func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteId, error) {
return ids, nil
}
-func (d *NoteDAO) findIdByPathPrefix(path string) (core.NoteId, error) {
+func (d *NoteDAO) findIdByPathPrefix(path string) (core.NoteID, error) {
row, err := d.findIdByPathPrefixStmt.QueryRow(path)
if err != nil {
- return core.NoteId(0), err
+ return 0, err
}
return idForRow(row)
}
-func idForRow(row *sql.Row) (core.NoteId, error) {
+func idForRow(row *sql.Row) (core.NoteID, error) {
var id sql.NullInt64
err := row.Scan(&id)
switch {
case err == sql.ErrNoRows:
- return core.NoteId(0), nil
+ return 0, nil
case err != nil:
- return core.NoteId(0), err
+ return 0, err
default:
- return core.NoteId(id.Int64), nil
+ return core.NoteID(id.Int64), nil
}
}
-// FindByHref returns the first note matching the given link href.
-func (d *NoteDAO) FindByHref(href string) (*note.Match, error) {
- id, err := d.findIdByPathPrefix(href)
+func (d *NoteDAO) FindMinimal(opts core.NoteFindOpts) ([]core.MinimalNote, error) {
+ notes := make([]core.MinimalNote, 0)
+
+ opts, err := d.expandMentionsIntoMatch(opts)
if err != nil {
- return nil, err
+ return notes, err
}
- row, err := d.findByIdStmt.QueryRow(id)
+
+ rows, err := d.findRows(opts, true)
if err != nil {
+ return notes, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ note, err := d.scanMinimalNote(rows)
+ if err != nil {
+ d.logger.Err(err)
+ continue
+ }
+ if note != nil {
+ notes = append(notes, *note)
+ }
+ }
+
+ return notes, nil
+}
+
+func (d *NoteDAO) scanMinimalNote(row RowScanner) (*core.MinimalNote, error) {
+ var (
+ id int
+ path, title string
+ )
+
+ err := row.Scan(&id, &path, &title)
+ switch {
+ case err == sql.ErrNoRows:
+ return nil, nil
+ case err != nil:
return nil, err
+ default:
+ return &core.MinimalNote{
+ ID: core.NoteID(id),
+ Path: path,
+ Title: title,
+ }, nil
}
- return d.scanNoteMatch(row)
}
// Find returns all the notes matching the given criteria.
-func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
- matches := make([]note.Match, 0)
+func (d *NoteDAO) Find(opts core.NoteFindOpts) ([]core.ContextualNote, error) {
+ notes := make([]core.ContextualNote, 0)
opts, err := d.expandMentionsIntoMatch(opts)
if err != nil {
- return matches, err
+ return notes, err
}
- rows, err := d.findRows(opts)
+ rows, err := d.findRows(opts, false)
if err != nil {
- return matches, err
+ return notes, err
}
defer rows.Close()
for rows.Next() {
- match, err := d.scanNoteMatch(rows)
+ note, err := d.scanNote(rows)
if err != nil {
d.logger.Err(err)
continue
}
- if match != nil {
- matches = append(matches, *match)
+ if note != nil {
+ notes = append(notes, *note)
}
}
- return matches, nil
+ return notes, nil
}
-func (d *NoteDAO) scanNoteMatch(row RowScanner) (*note.Match, error) {
+func (d *NoteDAO) scanNote(row RowScanner) (*core.ContextualNote, error) {
var (
id, wordCount int
title, lead, body, rawContent string
@@ -375,16 +413,17 @@ func (d *NoteDAO) scanNoteMatch(row RowScanner) (*note.Match, error) {
d.logger.Err(errors.Wrap(err, path))
}
- return ¬e.Match{
+ return &core.ContextualNote{
Snippets: parseListFromNullString(snippets),
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: core.NoteID(id),
Path: path,
Title: title,
Lead: lead,
Body: body,
RawContent: rawContent,
WordCount: wordCount,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: parseListFromNullString(tags),
Metadata: metadata,
Created: created,
@@ -407,7 +446,7 @@ func parseListFromNullString(str sql.NullString) []string {
// expandMentionsIntoMatch finds the titles associated with the notes in opts.Mention to
// expand them into the opts.Match predicate.
-func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts, error) {
+func (d *NoteDAO) expandMentionsIntoMatch(opts core.NoteFindOpts) (core.NoteFindOpts, error) {
if opts.Mention == nil {
return opts, nil
}
@@ -419,7 +458,9 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts
}
// Exclude the mentioned notes from the results.
- opts = opts.ExcludingIds(ids...)
+ for _, id := range ids {
+ opts = opts.ExcludingID(id)
+ }
// Find their titles.
titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + d.joinIds(ids, ",") + ")"
@@ -453,7 +494,7 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts
return opts, nil
}
-func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
+func (d *NoteDAO) findRows(opts core.NoteFindOpts, minimal bool) (*sql.Rows, error) {
snippetCol := `n.lead`
joinClauses := []string{}
whereExprs := []string{}
@@ -600,7 +641,7 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
SELECT note_id FROM notes_collections
WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
)`,
- note.CollectionKindTag,
+ core.CollectionKindTag,
strings.Join(globs, " OR "),
)
whereExprs = append(whereExprs, expr)
@@ -614,7 +655,9 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
}
// Exclude the mentioning notes from the results.
- opts = opts.ExcludingIds(ids...)
+ for _, id := range ids {
+ opts = opts.ExcludingID(id)
+ }
snippetCol = `snippet(nsrc.notes_fts, 2, '', '', '…', 20)`
joinClauses = append(joinClauses, "JOIN notes_fts nsrc ON nsrc.rowid IN ("+d.joinIds(ids, ",")+") AND nsrc.notes_fts MATCH mention_query(n.title, n.metadata)")
@@ -673,8 +716,8 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
args = append(args, opts.ModifiedEnd)
}
- if opts.ExcludeIds != nil {
- whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIds, ",")+")")
+ if opts.ExcludeIDs != nil {
+ whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIDs, ",")+")")
}
orderTerms := []string{}
@@ -716,9 +759,12 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
query += "\n)\n"
}
- query += fmt.Sprintf("SELECT n.id, n.path, n.title, n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.metadata, n.checksum, n.tags, %s AS snippet\n", snippetCol)
+ query += "SELECT n.id, n.path, n.title"
+ if !minimal {
+ query += fmt.Sprintf(", n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.metadata, n.checksum, n.tags, %s AS snippet", snippetCol)
+ }
- query += "FROM notes_with_metadata n\n"
+ query += "\nFROM notes_with_metadata n\n"
for _, clause := range joinClauses {
query += clause + "\n"
@@ -738,32 +784,35 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
query += fmt.Sprintf("LIMIT %d\n", opts.Limit)
}
- // fmt.Println(query)
- // fmt.Println(args)
+ // d.logger.Println(query)
+ // d.logger.Println(args)
+
return d.tx.Query(query, args...)
}
-func orderTerm(sorter note.Sorter) string {
+func orderTerm(sorter core.NoteSorter) string {
order := " ASC"
if !sorter.Ascending {
order = " DESC"
}
switch sorter.Field {
- case note.SortCreated:
+ case core.NoteSortCreated:
return "n.created" + order
- case note.SortModified:
+ case core.NoteSortModified:
return "n.modified" + order
- case note.SortPath:
+ case core.NoteSortPath:
return "n.path" + order
- case note.SortRandom:
+ case core.NoteSortRandom:
return "RANDOM()"
- case note.SortTitle:
+ case core.NoteSortTitle:
return "n.title" + order
- case note.SortWordCount:
+ case core.NoteSortWordCount:
return "n.word_count" + order
+ case core.NoteSortPathLength:
+ return "LENGTH(path)" + order
default:
- panic(fmt.Sprintf("%v: unknown note.SortField", sorter.Field))
+ panic(fmt.Sprintf("%v: unknown core.NoteSortField", sorter.Field))
}
}
@@ -774,7 +823,7 @@ func pathRegex(path string) string {
return path + "[^/]*|" + path + "/.+"
}
-func (d *NoteDAO) idToSql(id core.NoteId) sql.NullInt64 {
+func (d *NoteDAO) idToSql(id core.NoteID) sql.NullInt64 {
if id.IsValid() {
return sql.NullInt64{Int64: int64(id), Valid: true}
} else {
@@ -782,7 +831,7 @@ func (d *NoteDAO) idToSql(id core.NoteId) sql.NullInt64 {
}
}
-func (d *NoteDAO) joinIds(ids []core.NoteId, delimiter string) string {
+func (d *NoteDAO) joinIds(ids []core.NoteID, delimiter string) string {
strs := make([]string, 0)
for _, i := range ids {
strs = append(strs, strconv.FormatInt(int64(i), 10))
diff --git a/adapter/sqlite/note_dao_test.go b/internal/adapter/sqlite/note_dao_test.go
similarity index 79%
rename from adapter/sqlite/note_dao_test.go
rename to internal/adapter/sqlite/note_dao_test.go
index 778cb90..f422162 100644
--- a/adapter/sqlite/note_dao_test.go
+++ b/internal/adapter/sqlite/note_dao_test.go
@@ -6,17 +6,16 @@ import (
"testing"
"time"
- "github.com/mickael-menu/zk/core"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/opt"
- "github.com/mickael-menu/zk/util/paths"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ "github.com/mickael-menu/zk/internal/util/paths"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestNoteDAOIndexed(t *testing.T) {
testNoteDAOWithFixtures(t, "", func(tx Transaction, dao *NoteDAO) {
- for _, note := range []note.Metadata{
+ for _, note := range []core.Note{
{
Path: "a.md",
Modified: time.Date(2020, 1, 20, 8, 52, 42, 0, time.UTC),
@@ -105,7 +104,7 @@ func TestNoteDAOIndexed(t *testing.T) {
func TestNoteDAOAdd(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
- _, err := dao.Add(note.Metadata{
+ _, err := dao.Add(core.Note{
Path: "log/added.md",
Title: "Added note",
Lead: "Note",
@@ -138,13 +137,13 @@ func TestNoteDAOAdd(t *testing.T) {
func TestNoteDAOAddWithLinks(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
- id, err := dao.Add(note.Metadata{
+ id, err := dao.Add(core.Note{
Path: "log/added.md",
- Links: []note.Link{
+ Links: []core.Link{
{
Title: "Same dir",
Href: "log/2021-01-04",
- Rels: []string{"rel-1", "rel-2"},
+ Rels: core.LinkRels("rel-1", "rel-2"),
},
{
Title: "Relative",
@@ -156,17 +155,17 @@ func TestNoteDAOAddWithLinks(t *testing.T) {
{
Title: "Second is added",
Href: "f39c8",
- Rels: []string{"second"},
+ Rels: core.LinkRels("second"),
},
{
Title: "Unknown",
Href: "unknown",
},
{
- Title: "URL",
- Href: "http://example.com",
- External: true,
- Snippet: "External [URL](http://example.com)",
+ Title: "URL",
+ Href: "http://example.com",
+ IsExternal: true,
+ Snippet: "External [URL](http://example.com)",
},
},
})
@@ -206,13 +205,13 @@ func TestNoteDAOAddWithLinks(t *testing.T) {
Rels: "",
},
{
- SourceId: id,
- TargetId: nil,
- Title: "URL",
- Href: "http://example.com",
- External: true,
- Rels: "",
- Snippet: "External [URL](http://example.com)",
+ SourceId: id,
+ TargetId: nil,
+ Title: "URL",
+ Href: "http://example.com",
+ IsExternal: true,
+ Rels: "",
+ Snippet: "External [URL](http://example.com)",
},
})
})
@@ -220,7 +219,7 @@ func TestNoteDAOAddWithLinks(t *testing.T) {
func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
- id, err := dao.Add(note.Metadata{
+ id, err := dao.Add(core.Note{
Path: "missing_target.md",
})
assert.Nil(t, err)
@@ -241,14 +240,14 @@ func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) {
// Check that we can't add a duplicate note with an existing path.
func TestNoteDAOAddExistingNote(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
- _, err := dao.Add(note.Metadata{Path: "ref/test/a.md"})
+ _, err := dao.Add(core.Note{Path: "ref/test/a.md"})
assert.Err(t, err, "UNIQUE constraint failed: notes.path")
})
}
func TestNoteDAOUpdate(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
- id, err := dao.Update(note.Metadata{
+ id, err := dao.Update(core.Note{
Path: "ref/test/a.md",
Title: "Updated note",
Lead: "Updated lead",
@@ -261,7 +260,7 @@ func TestNoteDAOUpdate(t *testing.T) {
Modified: time.Date(2020, 11, 22, 16, 49, 47, 0, time.UTC),
})
assert.Nil(t, err)
- assert.Equal(t, id, core.NoteId(6))
+ assert.Equal(t, id, core.NoteID(6))
row, err := queryNoteRow(tx, `path = "ref/test/a.md"`)
assert.Nil(t, err)
@@ -282,7 +281,7 @@ func TestNoteDAOUpdate(t *testing.T) {
func TestNoteDAOUpdateUnknown(t *testing.T) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
- _, err := dao.Update(note.Metadata{
+ _, err := dao.Update(core.Note{
Path: "unknown/unknown.md",
})
assert.Err(t, err, "note not found in the index")
@@ -301,30 +300,30 @@ func TestNoteDAOUpdateWithLinks(t *testing.T) {
Snippet: "[[An internal link]]",
},
{
- SourceId: 1,
- TargetId: nil,
- Title: "An external link",
- Href: "https://domain.com",
- External: true,
- Snippet: "[[An external link]]",
+ SourceId: 1,
+ TargetId: nil,
+ Title: "An external link",
+ Href: "https://domain.com",
+ IsExternal: true,
+ Snippet: "[[An external link]]",
},
})
- _, err := dao.Update(note.Metadata{
+ _, err := dao.Update(core.Note{
Path: "log/2021-01-03.md",
- Links: []note.Link{
+ Links: []core.Link{
{
- Title: "A new link",
- Href: "index",
- External: false,
- Rels: []string{"rel"},
- Snippet: "[[A new link]]",
+ Title: "A new link",
+ Href: "index",
+ IsExternal: false,
+ Rels: core.LinkRels("rel"),
+ Snippet: "[[A new link]]",
},
{
- Title: "An external link",
- Href: "https://domain.com",
- External: true,
- Snippet: "[[An external link]]",
+ Title: "An external link",
+ Href: "https://domain.com",
+ IsExternal: true,
+ Snippet: "[[An external link]]",
},
},
})
@@ -341,12 +340,12 @@ func TestNoteDAOUpdateWithLinks(t *testing.T) {
Snippet: "[[A new link]]",
},
{
- SourceId: 1,
- TargetId: nil,
- Title: "An external link",
- Href: "https://domain.com",
- External: true,
- Snippet: "[[An external link]]",
+ SourceId: 1,
+ TargetId: nil,
+ Title: "An external link",
+ Href: "https://domain.com",
+ IsExternal: true,
+ Snippet: "[[An external link]]",
},
})
})
@@ -379,7 +378,7 @@ func TestNoteDAORemoveCascadeLinks(t *testing.T) {
assert.Equal(t, len(links) > 0, true)
links = queryLinkRows(t, tx, `id = 4`)
- assert.Equal(t, *links[0].TargetId, core.NoteId(1))
+ assert.Equal(t, *links[0].TargetId, core.NoteID(1))
err := dao.Remove("log/2021-01-03.md")
assert.Nil(t, err)
@@ -392,76 +391,49 @@ func TestNoteDAORemoveCascadeLinks(t *testing.T) {
})
}
-func TestNoteDAOFindByHref(t *testing.T) {
- test := func(href string, expected *note.Match) {
- testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
- actual, err := dao.FindByHref(href)
- assert.Nil(t, err)
- assert.Equal(t, actual, expected)
- })
- }
-
- test("not-found", nil)
-
- // Full path
- test("log/2021-01-03.md", ¬e.Match{
- Metadata: note.Metadata{
- Path: "log/2021-01-03.md",
- Title: "Daily note",
- Lead: "A daily note",
- Body: "A daily note\n\nWith lot of content",
- RawContent: "# A daily note\nA daily note\n\nWith lot of content",
- WordCount: 3,
- Links: []note.Link{},
- Tags: []string{"fiction", "adventure"},
- Metadata: map[string]interface{}{
- "author": "Dom",
- },
- Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
- Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
- Checksum: "qwfpgj",
- },
- Snippets: []string{"A daily note"},
- })
+func TestNoteDAOFindMinimalAll(t *testing.T) {
+ testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
+ notes, err := dao.FindMinimal(core.NoteFindOpts{})
+ assert.Nil(t, err)
- // Prefix
- test("log/2021-01", ¬e.Match{
- Metadata: note.Metadata{
- Path: "log/2021-01-03.md",
- Title: "Daily note",
- Lead: "A daily note",
- Body: "A daily note\n\nWith lot of content",
- RawContent: "# A daily note\nA daily note\n\nWith lot of content",
- WordCount: 3,
- Links: []note.Link{},
- Tags: []string{"fiction", "adventure"},
- Metadata: map[string]interface{}{
- "author": "Dom",
- },
- Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
- Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC),
- Checksum: "qwfpgj",
- },
- Snippets: []string{"A daily note"},
+ assert.Equal(t, notes, []core.MinimalNote{
+ {ID: 5, Path: "ref/test/b.md", Title: "A nested note"},
+ {ID: 4, Path: "f39c8.md", Title: "An interesting note"},
+ {ID: 6, Path: "ref/test/a.md", Title: "Another nested note"},
+ {ID: 1, Path: "log/2021-01-03.md", Title: "Daily note"},
+ {ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021"},
+ {ID: 3, Path: "index.md", Title: "Index"},
+ {ID: 2, Path: "log/2021-01-04.md", Title: "January 4, 2021"},
+ })
})
+}
- // Prefix matches the shortest path (fixes https://github.com/mickael-menu/zk/issues/23)
- testNoteDAOWithFixtures(t, "issue23", func(tx Transaction, dao *NoteDAO) {
- actual, err := dao.FindByHref("prefix")
+func TestNoteDAOFindMinimalWithFilter(t *testing.T) {
+ testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
+ notes, err := dao.FindMinimal(core.NoteFindOpts{
+ Match: opt.NewString("daily | index"),
+ Sorters: []core.NoteSorter{{Field: core.NoteSortWordCount, Ascending: true}},
+ Limit: 3,
+ })
assert.Nil(t, err)
- assert.Equal(t, actual.Path, "prefix-short.md")
+
+ assert.Equal(t, notes, []core.MinimalNote{
+ {ID: 1, Path: "log/2021-01-03.md", Title: "Daily note"},
+ {ID: 3, Path: "index.md", Title: "Index"},
+ {ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021"},
+ })
})
}
func TestNoteDAOFindAll(t *testing.T) {
- testNoteDAOFindPaths(t, note.FinderOpts{}, []string{
+ testNoteDAOFindPaths(t, core.NoteFindOpts{}, []string{
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md",
"log/2021-02-04.md", "index.md", "log/2021-01-04.md",
})
}
func TestNoteDAOFindLimit(t *testing.T) {
- testNoteDAOFindPaths(t, note.FinderOpts{Limit: 2}, []string{
+ testNoteDAOFindPaths(t, core.NoteFindOpts{Limit: 2}, []string{
"ref/test/b.md",
"f39c8.md",
})
@@ -469,7 +441,7 @@ func TestNoteDAOFindLimit(t *testing.T) {
func TestNoteDAOFindTag(t *testing.T) {
test := func(tags []string, expectedPaths []string) {
- testNoteDAOFindPaths(t, note.FinderOpts{Tags: tags}, expectedPaths)
+ testNoteDAOFindPaths(t, core.NoteFindOpts{Tags: tags}, expectedPaths)
}
test([]string{"fiction"}, []string{"log/2021-01-03.md"})
@@ -488,17 +460,18 @@ func TestNoteDAOFindTag(t *testing.T) {
func TestNoteDAOFindMatch(t *testing.T) {
testNoteDAOFind(t,
- note.FinderOpts{Match: opt.NewString("daily | index")},
- []note.Match{
+ core.NoteFindOpts{Match: opt.NewString("daily | index")},
+ []core.ContextualNote{
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 3,
Path: "index.md",
Title: "Index",
Lead: "Index of the Zettelkasten",
Body: "Index of the Zettelkasten",
RawContent: "# Index\nIndex of the Zettelkasten",
WordCount: 4,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{
"aliases": []interface{}{"First page"},
@@ -510,14 +483,15 @@ func TestNoteDAOFindMatch(t *testing.T) {
Snippets: []string{"Index of the Zettelkasten"},
},
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 1,
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
@@ -529,14 +503,15 @@ func TestNoteDAOFindMatch(t *testing.T) {
Snippets: []string{"A daily note\n\nWith lot of content"},
},
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 7,
Path: "log/2021-02-04.md",
Title: "February 4, 2021",
Lead: "A third daily note",
Body: "A third daily note",
RawContent: "# A third daily note",
WordCount: 4,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@@ -546,14 +521,15 @@ func TestNoteDAOFindMatch(t *testing.T) {
Snippets: []string{"A third daily note"},
},
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 2,
Path: "log/2021-01-04.md",
Title: "January 4, 2021",
Lead: "A second daily note",
Body: "A second daily note",
RawContent: "# A second daily note",
WordCount: 4,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@@ -568,10 +544,10 @@ func TestNoteDAOFindMatch(t *testing.T) {
func TestNoteDAOFindMatchWithSort(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
Match: opt.NewString("daily | index"),
- Sorters: []note.Sorter{
- {Field: note.SortPath, Ascending: false},
+ Sorters: []core.NoteSorter{
+ {Field: core.NoteSortPath, Ascending: false},
},
},
[]string{
@@ -585,7 +561,7 @@ func TestNoteDAOFindMatchWithSort(t *testing.T) {
func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
IncludePaths: []string{"log/2021-01-03.md"},
},
[]string{"log/2021-01-03.md"},
@@ -595,7 +571,7 @@ func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) {
// You can look for files with only their prefix.
func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
IncludePaths: []string{"log/2021-01"},
},
[]string{"log/2021-01-03.md", "log/2021-01-04.md"},
@@ -605,13 +581,13 @@ func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) {
// For directory, only complete names work, no prefixes.
func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
IncludePaths: []string{"lo"},
},
[]string{},
)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
IncludePaths: []string{"log"},
},
[]string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"},
@@ -621,7 +597,7 @@ func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) {
// You can look for multiple paths, in which case notes can be in any of them.
func TestNoteDAOFindInMultiplePaths(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
IncludePaths: []string{"ref", "index.md"},
},
[]string{"ref/test/b.md", "ref/test/a.md", "index.md"},
@@ -630,7 +606,7 @@ func TestNoteDAOFindInMultiplePaths(t *testing.T) {
func TestNoteDAOFindExcludingPath(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
ExcludePaths: []string{"log"},
},
[]string{"ref/test/b.md", "f39c8.md", "ref/test/a.md", "index.md"},
@@ -639,7 +615,7 @@ func TestNoteDAOFindExcludingPath(t *testing.T) {
func TestNoteDAOFindExcludingMultiplePaths(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
ExcludePaths: []string{"ref", "log/2021-01"},
},
[]string{"f39c8.md", "log/2021-02-04.md", "index.md"},
@@ -648,17 +624,18 @@ func TestNoteDAOFindExcludingMultiplePaths(t *testing.T) {
func TestNoteDAOFindMentions(t *testing.T) {
testNoteDAOFind(t,
- note.FinderOpts{Mention: []string{"log/2021-01-03.md", "index.md"}},
- []note.Match{
+ core.NoteFindOpts{Mention: []string{"log/2021-01-03.md", "index.md"}},
+ []core.ContextualNote{
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 5,
Path: "ref/test/b.md",
Title: "A nested note",
Lead: "This one is in a sub sub directory",
Body: "This one is in a sub sub directory, not the first page",
RawContent: "# A nested note\nThis one is in a sub sub directory",
WordCount: 8,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{"adventure", "history"},
Metadata: map[string]interface{}{},
Created: time.Date(2019, 11, 20, 20, 32, 56, 0, time.UTC),
@@ -668,14 +645,15 @@ func TestNoteDAOFindMentions(t *testing.T) {
Snippets: []string{"This one is in a sub sub directory, not the first page"},
},
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 7,
Path: "log/2021-02-04.md",
Title: "February 4, 2021",
Lead: "A third daily note",
Body: "A third daily note",
RawContent: "# A third daily note",
WordCount: 4,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@@ -685,14 +663,15 @@ func TestNoteDAOFindMentions(t *testing.T) {
Snippets: []string{"A third daily note"},
},
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 2,
Path: "log/2021-01-04.md",
Title: "January 4, 2021",
Lead: "A second daily note",
Body: "A second daily note",
RawContent: "# A second daily note",
WordCount: 4,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{},
Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC),
@@ -708,9 +687,9 @@ func TestNoteDAOFindMentions(t *testing.T) {
// Common use case: `--mention x --no-link-to x`
func TestNoteDAOFindUnlinkedMentions(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
Mention: []string{"log/2021-01-03.md", "index.md"},
- LinkTo: ¬e.LinkFilter{
+ LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-03.md", "index.md"},
Negate: true,
},
@@ -721,17 +700,18 @@ func TestNoteDAOFindUnlinkedMentions(t *testing.T) {
func TestNoteDAOFindMentionedBy(t *testing.T) {
testNoteDAOFind(t,
- note.FinderOpts{MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"}},
- []note.Match{
+ core.NoteFindOpts{MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"}},
+ []core.ContextualNote{
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 1,
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
@@ -743,14 +723,15 @@ func TestNoteDAOFindMentionedBy(t *testing.T) {
Snippets: []string{"A second daily note"},
},
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 3,
Path: "index.md",
Title: "Index",
Lead: "Index of the Zettelkasten",
Body: "Index of the Zettelkasten",
RawContent: "# Index\nIndex of the Zettelkasten",
WordCount: 4,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{
"aliases": []interface{}{
@@ -770,9 +751,9 @@ func TestNoteDAOFindMentionedBy(t *testing.T) {
// Common use case: `--mentioned-by x --no-linked-by x`
func TestNoteDAOFindUnlinkedMentionedBy(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"},
- LinkedBy: ¬e.LinkFilter{
+ LinkedBy: &core.LinkFilter{
Paths: []string{"ref/test/b.md", "log/2021-01-04.md"},
Negate: true,
},
@@ -783,8 +764,8 @@ func TestNoteDAOFindUnlinkedMentionedBy(t *testing.T) {
func TestNoteDAOFindLinkedBy(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkedBy: ¬e.LinkFilter{
+ core.NoteFindOpts{
+ LinkedBy: &core.LinkFilter{
Paths: []string{"f39c8.md", "log/2021-01-03"},
Negate: false,
Recursive: false,
@@ -796,8 +777,8 @@ func TestNoteDAOFindLinkedBy(t *testing.T) {
func TestNoteDAOFindLinkedByRecursive(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkedBy: ¬e.LinkFilter{
+ core.NoteFindOpts{
+ LinkedBy: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@@ -809,8 +790,8 @@ func TestNoteDAOFindLinkedByRecursive(t *testing.T) {
func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkedBy: ¬e.LinkFilter{
+ core.NoteFindOpts{
+ LinkedBy: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@@ -823,19 +804,20 @@ func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) {
func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
testNoteDAOFind(t,
- note.FinderOpts{
- LinkedBy: ¬e.LinkFilter{Paths: []string{"f39c8.md"}},
+ core.NoteFindOpts{
+ LinkedBy: &core.LinkFilter{Paths: []string{"f39c8.md"}},
},
- []note.Match{
+ []core.ContextualNote{
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 6,
Path: "ref/test/a.md",
Title: "Another nested note",
Lead: "It shall appear before b.md",
Body: "It shall appear before b.md",
RawContent: "#Another nested note\nIt shall appear before b.md",
WordCount: 5,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{},
Metadata: map[string]interface{}{
"alias": "a.md",
@@ -850,14 +832,15 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
},
},
{
- Metadata: note.Metadata{
+ Note: core.Note{
+ ID: 1,
Path: "log/2021-01-03.md",
Title: "Daily note",
Lead: "A daily note",
Body: "A daily note\n\nWith lot of content",
RawContent: "# A daily note\nA daily note\n\nWith lot of content",
WordCount: 3,
- Links: []note.Link{},
+ Links: []core.Link{},
Tags: []string{"fiction", "adventure"},
Metadata: map[string]interface{}{
"author": "Dom",
@@ -876,8 +859,8 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) {
func TestNoteDAOFindNotLinkedBy(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkedBy: ¬e.LinkFilter{
+ core.NoteFindOpts{
+ LinkedBy: &core.LinkFilter{
Paths: []string{"f39c8.md", "log/2021-01-03"},
Negate: true,
Recursive: false,
@@ -889,8 +872,8 @@ func TestNoteDAOFindNotLinkedBy(t *testing.T) {
func TestNoteDAOFindLinkTo(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkTo: ¬e.LinkFilter{
+ core.NoteFindOpts{
+ LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-04", "ref/test/a.md"},
Negate: false,
Recursive: false,
@@ -902,8 +885,8 @@ func TestNoteDAOFindLinkTo(t *testing.T) {
func TestNoteDAOFindLinkToRecursive(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkTo: ¬e.LinkFilter{
+ core.NoteFindOpts{
+ LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@@ -915,8 +898,8 @@ func TestNoteDAOFindLinkToRecursive(t *testing.T) {
func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkTo: ¬e.LinkFilter{
+ core.NoteFindOpts{
+ LinkTo: &core.LinkFilter{
Paths: []string{"log/2021-01-04.md"},
Negate: false,
Recursive: true,
@@ -929,8 +912,8 @@ func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) {
func TestNoteDAOFindNotLinkTo(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- LinkTo: ¬e.LinkFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true},
+ core.NoteFindOpts{
+ LinkTo: &core.LinkFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true},
},
[]string{"ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md"},
)
@@ -938,14 +921,14 @@ func TestNoteDAOFindNotLinkTo(t *testing.T) {
func TestNoteDAOFindRelated(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
Related: []string{"log/2021-02-04"},
},
[]string{},
)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
Related: []string{"log/2021-01-03.md"},
},
[]string{"index.md"},
@@ -954,7 +937,7 @@ func TestNoteDAOFindRelated(t *testing.T) {
func TestNoteDAOFindOrphan(t *testing.T) {
testNoteDAOFindPaths(t,
- note.FinderOpts{Orphan: true},
+ core.NoteFindOpts{Orphan: true},
[]string{"ref/test/b.md", "log/2021-02-04.md"},
)
}
@@ -963,7 +946,7 @@ func TestNoteDAOFindCreatedOn(t *testing.T) {
start := time.Date(2020, 11, 22, 0, 0, 0, 0, time.UTC)
end := time.Date(2020, 11, 23, 0, 0, 0, 0, time.UTC)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
CreatedStart: &start,
CreatedEnd: &end,
},
@@ -974,7 +957,7 @@ func TestNoteDAOFindCreatedOn(t *testing.T) {
func TestNoteDAOFindCreatedBefore(t *testing.T) {
end := time.Date(2019, 12, 04, 11, 59, 11, 0, time.UTC)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
CreatedEnd: &end,
},
[]string{"ref/test/b.md", "ref/test/a.md"},
@@ -984,7 +967,7 @@ func TestNoteDAOFindCreatedBefore(t *testing.T) {
func TestNoteDAOFindCreatedAfter(t *testing.T) {
start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
CreatedStart: &start,
},
[]string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"},
@@ -995,7 +978,7 @@ func TestNoteDAOFindModifiedOn(t *testing.T) {
start := time.Date(2020, 01, 20, 0, 0, 0, 0, time.UTC)
end := time.Date(2020, 01, 21, 0, 0, 0, 0, time.UTC)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
ModifiedStart: &start,
ModifiedEnd: &end,
},
@@ -1006,7 +989,7 @@ func TestNoteDAOFindModifiedOn(t *testing.T) {
func TestNoteDAOFindModifiedBefore(t *testing.T) {
end := time.Date(2020, 01, 20, 8, 52, 42, 0, time.UTC)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
ModifiedEnd: &end,
},
[]string{"ref/test/b.md", "ref/test/a.md", "index.md"},
@@ -1016,7 +999,7 @@ func TestNoteDAOFindModifiedBefore(t *testing.T) {
func TestNoteDAOFindModifiedAfter(t *testing.T) {
start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC)
testNoteDAOFindPaths(t,
- note.FinderOpts{
+ core.NoteFindOpts{
ModifiedStart: &start,
},
[]string{"log/2021-01-03.md", "log/2021-01-04.md"},
@@ -1024,70 +1007,70 @@ func TestNoteDAOFindModifiedAfter(t *testing.T) {
}
func TestNoteDAOFindSortCreated(t *testing.T) {
- testNoteDAOFindSort(t, note.SortCreated, true, []string{
+ testNoteDAOFindSort(t, core.NoteSortCreated, true, []string{
"ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md",
"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md",
})
- testNoteDAOFindSort(t, note.SortCreated, false, []string{
+ testNoteDAOFindSort(t, core.NoteSortCreated, false, []string{
"log/2021-02-04.md", "log/2021-01-04.md", "log/2021-01-03.md",
"f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md",
})
}
func TestNoteDAOFindSortModified(t *testing.T) {
- testNoteDAOFindSort(t, note.SortModified, true, []string{
+ testNoteDAOFindSort(t, core.NoteSortModified, true, []string{
"ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md",
"log/2021-02-04.md", "log/2021-01-03.md", "log/2021-01-04.md",
})
- testNoteDAOFindSort(t, note.SortModified, false, []string{
+ testNoteDAOFindSort(t, core.NoteSortModified, false, []string{
"log/2021-01-04.md", "log/2021-01-03.md", "log/2021-02-04.md",
"f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md",
})
}
func TestNoteDAOFindSortPath(t *testing.T) {
- testNoteDAOFindSort(t, note.SortPath, true, []string{
+ testNoteDAOFindSort(t, core.NoteSortPath, true, []string{
"f39c8.md", "index.md", "log/2021-01-03.md", "log/2021-01-04.md",
"log/2021-02-04.md", "ref/test/a.md", "ref/test/b.md",
})
- testNoteDAOFindSort(t, note.SortPath, false, []string{
+ testNoteDAOFindSort(t, core.NoteSortPath, false, []string{
"ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md",
"log/2021-01-04.md", "log/2021-01-03.md", "index.md", "f39c8.md",
})
}
func TestNoteDAOFindSortTitle(t *testing.T) {
- testNoteDAOFindSort(t, note.SortTitle, true, []string{
+ testNoteDAOFindSort(t, core.NoteSortTitle, true, []string{
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md",
"log/2021-02-04.md", "index.md", "log/2021-01-04.md",
})
- testNoteDAOFindSort(t, note.SortTitle, false, []string{
+ testNoteDAOFindSort(t, core.NoteSortTitle, false, []string{
"log/2021-01-04.md", "index.md", "log/2021-02-04.md",
"log/2021-01-03.md", "ref/test/a.md", "f39c8.md", "ref/test/b.md",
})
}
func TestNoteDAOFindSortWordCount(t *testing.T) {
- testNoteDAOFindSort(t, note.SortWordCount, true, []string{
+ testNoteDAOFindSort(t, core.NoteSortWordCount, true, []string{
"log/2021-01-03.md", "log/2021-02-04.md", "index.md",
"log/2021-01-04.md", "f39c8.md", "ref/test/a.md", "ref/test/b.md",
})
- testNoteDAOFindSort(t, note.SortWordCount, false, []string{
+ testNoteDAOFindSort(t, core.NoteSortWordCount, false, []string{
"ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md",
"index.md", "log/2021-01-04.md", "log/2021-01-03.md",
})
}
-func testNoteDAOFindSort(t *testing.T, field note.SortField, ascending bool, expected []string) {
+func testNoteDAOFindSort(t *testing.T, field core.NoteSortField, ascending bool, expected []string) {
testNoteDAOFindPaths(t,
- note.FinderOpts{
- Sorters: []note.Sorter{{Field: field, Ascending: ascending}},
+ core.NoteFindOpts{
+ Sorters: []core.NoteSorter{{Field: field, Ascending: ascending}},
},
expected,
)
}
-func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string) {
+func testNoteDAOFindPaths(t *testing.T, opts core.NoteFindOpts, expected []string) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
matches, err := dao.Find(opts)
assert.Nil(t, err)
@@ -1100,7 +1083,7 @@ func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string)
})
}
-func testNoteDAOFind(t *testing.T, opts note.FinderOpts, expected []note.Match) {
+func testNoteDAOFind(t *testing.T, opts core.NoteFindOpts, expected []core.ContextualNote) {
testNoteDAO(t, func(tx Transaction, dao *NoteDAO) {
actual, err := dao.Find(opts)
assert.Nil(t, err)
@@ -1137,11 +1120,11 @@ func queryNoteRow(tx Transaction, where string) (noteRow, error) {
}
type linkRow struct {
- SourceId core.NoteId
- TargetId *core.NoteId
+ SourceId core.NoteID
+ TargetId *core.NoteID
Href, Title, Rels, Snippet string
SnippetStart, SnippetEnd int
- External bool
+ IsExternal bool
}
func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow {
@@ -1159,9 +1142,9 @@ func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow {
var row linkRow
var sourceId int64
var targetId *int64
- err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.External, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd)
+ err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.IsExternal, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd)
assert.Nil(t, err)
- row.SourceId = core.NoteId(sourceId)
+ row.SourceId = core.NoteID(sourceId)
if targetId != nil {
row.TargetId = idPointer(*targetId)
}
@@ -1173,7 +1156,7 @@ func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow {
return links
}
-func idPointer(i int64) *core.NoteId {
- id := core.NoteId(i)
+func idPointer(i int64) *core.NoteID {
+ id := core.NoteID(i)
return &id
}
diff --git a/internal/adapter/sqlite/note_index.go b/internal/adapter/sqlite/note_index.go
new file mode 100644
index 0000000..619a5f3
--- /dev/null
+++ b/internal/adapter/sqlite/note_index.go
@@ -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)
+ })
+ }
+}
diff --git a/internal/adapter/sqlite/note_index_test.go b/internal/adapter/sqlite/note_index_test.go
new file mode 100644
index 0000000..13fb26b
--- /dev/null
+++ b/internal/adapter/sqlite/note_index_test.go
@@ -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)
+}
diff --git a/adapter/sqlite/stmt.go b/internal/adapter/sqlite/stmt.go
similarity index 96%
rename from adapter/sqlite/stmt.go
rename to internal/adapter/sqlite/stmt.go
index a595379..7a3eae8 100644
--- a/adapter/sqlite/stmt.go
+++ b/internal/adapter/sqlite/stmt.go
@@ -4,7 +4,7 @@ import (
"database/sql"
"sync"
- "github.com/mickael-menu/zk/util/errors"
+ "github.com/mickael-menu/zk/internal/util/errors"
)
// LazyStmt is a wrapper around a sql.Stmt which will be evaluated on first use.
diff --git a/adapter/sqlite/fixtures/default/collections.yml b/internal/adapter/sqlite/testdata/default/collections.yml
similarity index 84%
rename from adapter/sqlite/fixtures/default/collections.yml
rename to internal/adapter/sqlite/testdata/default/collections.yml
index 9051d1f..334f7e6 100644
--- a/adapter/sqlite/fixtures/default/collections.yml
+++ b/internal/adapter/sqlite/testdata/default/collections.yml
@@ -13,3 +13,6 @@
- id: 5
kind: "tag"
name: "history"
+- id: 6
+ kind: "tag"
+ name: "empty"
diff --git a/adapter/sqlite/fixtures/default/links.yml b/internal/adapter/sqlite/testdata/default/links.yml
similarity index 100%
rename from adapter/sqlite/fixtures/default/links.yml
rename to internal/adapter/sqlite/testdata/default/links.yml
diff --git a/internal/adapter/sqlite/testdata/default/metadata.yml b/internal/adapter/sqlite/testdata/default/metadata.yml
new file mode 100644
index 0000000..62f5444
--- /dev/null
+++ b/internal/adapter/sqlite/testdata/default/metadata.yml
@@ -0,0 +1,2 @@
+- key: "a_metadata"
+ value: "value"
diff --git a/adapter/sqlite/fixtures/default/notes.yml b/internal/adapter/sqlite/testdata/default/notes.yml
similarity index 100%
rename from adapter/sqlite/fixtures/default/notes.yml
rename to internal/adapter/sqlite/testdata/default/notes.yml
diff --git a/adapter/sqlite/fixtures/default/notes_collections.yml b/internal/adapter/sqlite/testdata/default/notes_collections.yml
similarity index 100%
rename from adapter/sqlite/fixtures/default/notes_collections.yml
rename to internal/adapter/sqlite/testdata/default/notes_collections.yml
diff --git a/adapter/sqlite/fixtures/issue23/notes.yml b/internal/adapter/sqlite/testdata/issue23/notes.yml
similarity index 100%
rename from adapter/sqlite/fixtures/issue23/notes.yml
rename to internal/adapter/sqlite/testdata/issue23/notes.yml
diff --git a/internal/adapter/sqlite/testdata/sample.db b/internal/adapter/sqlite/testdata/sample.db
new file mode 100644
index 0000000..db374fb
--- /dev/null
+++ b/internal/adapter/sqlite/testdata/sample.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b7ba9b3ab5296c19e6a7a61ee4ca43116840ef956a1491c670bd7591abbd313
+size 86016
diff --git a/adapter/sqlite/transaction.go b/internal/adapter/sqlite/transaction.go
similarity index 100%
rename from adapter/sqlite/transaction.go
rename to internal/adapter/sqlite/transaction.go
diff --git a/internal/adapter/sqlite/transaction_test.go b/internal/adapter/sqlite/transaction_test.go
new file mode 100644
index 0000000..20bf95a
--- /dev/null
+++ b/internal/adapter/sqlite/transaction_test.go
@@ -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
+}
diff --git a/internal/adapter/term/styler.go b/internal/adapter/term/styler.go
new file mode 100644
index 0000000..52df469
--- /dev/null
+++ b/internal/adapter/term/styler.go
@@ -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
+}
diff --git a/adapter/term/styler_test.go b/internal/adapter/term/styler_test.go
similarity index 83%
rename from adapter/term/styler_test.go
rename to internal/adapter/term/styler_test.go
index 1a40112..c9716ff 100644
--- a/adapter/term/styler_test.go
+++ b/internal/adapter/term/styler_test.go
@@ -4,8 +4,8 @@ import (
"testing"
"github.com/fatih/color"
- "github.com/mickael-menu/zk/core/style"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func createTerminal() *Terminal {
@@ -20,24 +20,24 @@ func TestStyleNoRule(t *testing.T) {
}
func TestStyleOneRule(t *testing.T) {
- res, err := createTerminal().Style("Hello", style.Rule("red"))
+ res, err := createTerminal().Style("Hello", core.Style("red"))
assert.Nil(t, err)
assert.Equal(t, res, "\033[31mHello\033[0m")
}
func TestStyleMultipleRule(t *testing.T) {
- res, err := createTerminal().Style("Hello", style.Rule("red"), style.Rule("bold"))
+ res, err := createTerminal().Style("Hello", core.Style("red"), core.Style("bold"))
assert.Nil(t, err)
assert.Equal(t, res, "\033[31;1mHello\033[0m")
}
func TestStyleUnknownRule(t *testing.T) {
- _, err := createTerminal().Style("Hello", style.Rule("unknown"))
+ _, err := createTerminal().Style("Hello", core.Style("unknown"))
assert.Err(t, err, "unknown styling rule: unknown")
}
func TestStyleEmptyString(t *testing.T) {
- res, err := createTerminal().Style("", style.Rule("bold"))
+ res, err := createTerminal().Style("", core.Style("bold"))
assert.Nil(t, err)
assert.Equal(t, res, "")
}
@@ -45,7 +45,7 @@ func TestStyleEmptyString(t *testing.T) {
func TestStyleAllRules(t *testing.T) {
styler := createTerminal()
test := func(rule string, expected string) {
- res, err := styler.Style("Hello", style.Rule(rule))
+ res, err := styler.Style("Hello", core.Style(rule))
assert.Nil(t, err)
assert.Equal(t, res, "\033["+expected+"Hello\033[0m")
}
diff --git a/adapter/term/term.go b/internal/adapter/term/term.go
similarity index 67%
rename from adapter/term/term.go
rename to internal/adapter/term/term.go
index 4aa97bc..dfe0db7 100644
--- a/adapter/term/term.go
+++ b/internal/adapter/term/term.go
@@ -4,6 +4,7 @@ import (
"os"
"strings"
+ survey "github.com/AlecAivazis/survey/v2"
"github.com/mattn/go-isatty"
)
@@ -33,3 +34,18 @@ func (t *Terminal) SupportsUTF8() bool {
lc := strings.ToUpper(os.Getenv("LC_ALL"))
return strings.Contains(lang, "UTF") || strings.Contains(lc, "UTF")
}
+
+// Confirm is a shortcut to prompt a yes/no question to the user.
+func (t *Terminal) Confirm(msg string, defaultAnswer bool) (confirmed, skipped bool) {
+ if !t.IsInteractive() {
+ return defaultAnswer, true
+ }
+
+ confirmed = false
+ prompt := &survey.Confirm{
+ Message: msg,
+ Default: defaultAnswer,
+ }
+ survey.AskOne(prompt, &confirmed)
+ return confirmed, false
+}
diff --git a/cmd/edit.go b/internal/cli/cmd/edit.go
similarity index 53%
rename from cmd/edit.go
rename to internal/cli/cmd/edit.go
index 407ac08..667d893 100644
--- a/cmd/edit.go
+++ b/internal/cli/cmd/edit.go
@@ -4,50 +4,46 @@ import (
"fmt"
"path/filepath"
- "github.com/mickael-menu/zk/adapter"
- "github.com/mickael-menu/zk/adapter/fzf"
- "github.com/mickael-menu/zk/adapter/sqlite"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/core/zk"
- "github.com/mickael-menu/zk/util/errors"
+ "github.com/mickael-menu/zk/internal/adapter/fzf"
+ "github.com/mickael-menu/zk/internal/cli"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util/errors"
)
// Edit opens notes matching a set of criteria with the user editor.
type Edit struct {
Force bool `short:f help:"Do not confirm before editing many notes at the same time."`
- Filtering
+ cli.Filtering
}
-func (cmd *Edit) Run(container *adapter.Container) error {
- zk, err := container.Zk()
+func (cmd *Edit) Run(container *cli.Container) error {
+ notebook, err := container.CurrentNotebook()
if err != nil {
return err
}
- opts, err := NewFinderOpts(zk, cmd.Filtering)
+ findOpts, err := cmd.Filtering.NewNoteFindOpts(notebook)
if err != nil {
return errors.Wrapf(err, "incorrect criteria")
}
- db, _, err := container.Database(false)
+ notes, err := notebook.FindNotes(findOpts)
if err != nil {
return err
}
- var notes []note.Match
- err = db.WithTransaction(func(tx sqlite.Transaction) error {
- finder := container.NoteFinder(tx, fzf.NoteFinderOpts{
- AlwaysFilter: true,
- PreviewCmd: container.Config.Tool.FzfPreview,
- NewNoteDir: cmd.newNoteDir(zk),
- BasePath: zk.Path,
- CurrentPath: container.WorkingDir,
- })
- notes, err = finder.Find(*opts)
- return err
+ filter := container.NewNoteFilter(fzf.NoteFilterOpts{
+ Interactive: cmd.Interactive,
+ AlwaysFilter: true,
+ PreviewCmd: container.Config.Tool.FzfPreview,
+ NewNoteDir: cmd.newNoteDir(notebook),
+ NotebookDir: notebook.Path,
+ WorkingDir: container.WorkingDir,
})
+
+ notes, err = filter.Apply(notes)
if err != nil {
- if err == note.ErrCanceled {
+ if err == fzf.ErrCancelled {
return nil
}
return err
@@ -66,32 +62,35 @@ func (cmd *Edit) Run(container *adapter.Container) error {
}
paths := make([]string, 0)
for _, note := range notes {
- absPath := filepath.Join(zk.Path, note.Path)
+ absPath := filepath.Join(notebook.Path, note.Path)
paths = append(paths, absPath)
}
- note.Edit(zk, paths...)
+ editor, err := container.NewNoteEditor(notebook)
+ if err != nil {
+ return err
+ }
+ return editor.Open(paths...)
} else {
fmt.Println("Found 0 note")
+ return nil
}
-
- return err
}
// newNoteDir returns the directory in which to create a new note when the fzf
// binding is triggered.
-func (cmd *Edit) newNoteDir(zk *zk.Zk) *zk.Dir {
+func (cmd *Edit) newNoteDir(notebook *core.Notebook) *core.Dir {
switch len(cmd.Path) {
case 0:
- dir := zk.RootDir()
+ dir := notebook.RootDir()
return &dir
case 1:
- dir, err := zk.DirAt(cmd.Path[0])
+ dir, err := notebook.DirAt(cmd.Path[0])
if err != nil {
return nil
}
- return dir
+ return &dir
default:
// More than one directory, it's ambiguous for the "new note" fzf binding.
return nil
diff --git a/cmd/index.go b/internal/cli/cmd/index.go
similarity index 64%
rename from cmd/index.go
rename to internal/cli/cmd/index.go
index 44b1f0b..eba08d3 100644
--- a/cmd/index.go
+++ b/internal/cli/cmd/index.go
@@ -3,7 +3,7 @@ package cmd
import (
"fmt"
- "github.com/mickael-menu/zk/adapter"
+ "github.com/mickael-menu/zk/internal/cli"
)
// Index indexes the content of all the notes in the notebook.
@@ -16,15 +16,20 @@ func (cmd *Index) Help() string {
return "You usually do not need to run `zk index` manually, as notes are indexed automatically when needed."
}
-func (cmd *Index) Run(container *adapter.Container) error {
- _, stats, err := container.Database(cmd.Force)
+func (cmd *Index) Run(container *cli.Container) error {
+ notebook, err := container.CurrentNotebook()
if err != nil {
return err
}
- if err == nil && !cmd.Quiet {
+ stats, err := notebook.Index(cmd.Force)
+ if err != nil {
+ return err
+ }
+
+ if !cmd.Quiet {
fmt.Println(stats)
}
- return err
+ return nil
}
diff --git a/internal/cli/cmd/init.go b/internal/cli/cmd/init.go
new file mode 100644
index 0000000..4db8ffa
--- /dev/null
+++ b/internal/cli/cmd/init.go
@@ -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
+}
diff --git a/internal/cli/cmd/list.go b/internal/cli/cmd/list.go
new file mode 100644
index 0000000..85fc635
--- /dev/null
+++ b/internal/cli/cmd/list.go
@@ -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}}
+`,
+}
diff --git a/internal/cli/cmd/list_test.go b/internal/cli/cmd/list_test.go
new file mode 100644
index 0000000..7bae4fc
--- /dev/null
+++ b/internal/cli/cmd/list_test.go
@@ -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}}")
+}
diff --git a/cmd/lsp.go b/internal/cli/cmd/lsp.go
similarity index 55%
rename from cmd/lsp.go
rename to internal/cli/cmd/lsp.go
index 58b8e98..d2181bf 100644
--- a/cmd/lsp.go
+++ b/internal/cli/cmd/lsp.go
@@ -1,9 +1,9 @@
package cmd
import (
- "github.com/mickael-menu/zk/adapter"
- "github.com/mickael-menu/zk/adapter/lsp"
- "github.com/mickael-menu/zk/util/opt"
+ "github.com/mickael-menu/zk/internal/adapter/lsp"
+ "github.com/mickael-menu/zk/internal/cli"
+ "github.com/mickael-menu/zk/internal/util/opt"
)
// LSP starts a server implementing the Language Server Protocol.
@@ -11,12 +11,14 @@ type LSP struct {
Log string `type:path placeholder:PATH help:"Absolute path to the log file"`
}
-func (cmd *LSP) Run(container *adapter.Container) error {
+func (cmd *LSP) Run(container *cli.Container) error {
server := lsp.NewServer(lsp.ServerOpts{
Name: "zk",
Version: container.Version,
+ Logger: container.Logger,
LogFile: opt.NewNotEmptyString(cmd.Log),
- Container: container,
+ Notebooks: container.Notebooks,
+ FS: container.FS,
})
return server.Run()
diff --git a/internal/cli/cmd/new.go b/internal/cli/cmd/new.go
new file mode 100644
index 0000000..a3e71c6
--- /dev/null
+++ b/internal/cli/cmd/new.go
@@ -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)
+ }
+}
diff --git a/internal/cli/container.go b/internal/cli/container.go
new file mode 100644
index 0000000..432e029
--- /dev/null
+++ b/internal/cli/container.go
@@ -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)
+ }
+}
diff --git a/cmd/finder_opts.go b/internal/cli/filtering.go
similarity index 75%
rename from cmd/finder_opts.go
rename to internal/cli/filtering.go
index 0ff2866..675f33b 100644
--- a/cmd/finder_opts.go
+++ b/internal/cli/filtering.go
@@ -1,4 +1,4 @@
-package cmd
+package cli
import (
"fmt"
@@ -7,11 +7,10 @@ import (
"github.com/alecthomas/kong"
"github.com/kballard/go-shellquote"
- "github.com/mickael-menu/zk/core/note"
- "github.com/mickael-menu/zk/core/zk"
- "github.com/mickael-menu/zk/util/errors"
- "github.com/mickael-menu/zk/util/opt"
- "github.com/mickael-menu/zk/util/strings"
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util/errors"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ "github.com/mickael-menu/zk/internal/util/strings"
"github.com/tj/go-naturaldate"
)
@@ -129,136 +128,134 @@ func (f Filtering) ExpandNamedFilters(filters map[string]string, expandedFilters
return f, nil
}
-// NewFinderOpts creates an instance of note.FinderOpts from a set of user flags.
-func NewFinderOpts(zk *zk.Zk, filtering Filtering) (*note.FinderOpts, error) {
- filtering, err := filtering.ExpandNamedFilters(zk.Config.Filters, []string{})
+// NewNoteFindOpts creates an instance of core.NoteFindOpts from a set of user flags.
+func (f Filtering) NewNoteFindOpts(notebook *core.Notebook) (core.NoteFindOpts, error) {
+ opts := core.NoteFindOpts{}
+
+ f, err := f.ExpandNamedFilters(notebook.Config.Filters, []string{})
if err != nil {
- return nil, err
+ return opts, err
}
- opts := note.FinderOpts{}
-
- opts.Match = opt.NewNotEmptyString(filtering.Match)
+ opts.Match = opt.NewNotEmptyString(f.Match)
- if paths, ok := relPaths(zk, filtering.Path); ok {
+ if paths, ok := relPaths(notebook, f.Path); ok {
opts.IncludePaths = paths
}
- if paths, ok := relPaths(zk, filtering.Exclude); ok {
+ if paths, ok := relPaths(notebook, f.Exclude); ok {
opts.ExcludePaths = paths
}
- if len(filtering.Tag) > 0 {
- opts.Tags = filtering.Tag
+ if len(f.Tag) > 0 {
+ opts.Tags = f.Tag
}
- if len(filtering.Mention) > 0 {
- opts.Mention = filtering.Mention
+ if len(f.Mention) > 0 {
+ opts.Mention = f.Mention
}
- if len(filtering.MentionedBy) > 0 {
- opts.MentionedBy = filtering.MentionedBy
+ if len(f.MentionedBy) > 0 {
+ opts.MentionedBy = f.MentionedBy
}
- if paths, ok := relPaths(zk, filtering.LinkedBy); ok {
- opts.LinkedBy = ¬e.LinkFilter{
+ if paths, ok := relPaths(notebook, f.LinkedBy); ok {
+ opts.LinkedBy = &core.LinkFilter{
Paths: paths,
Negate: false,
- Recursive: filtering.Recursive,
- MaxDistance: filtering.MaxDistance,
+ Recursive: f.Recursive,
+ MaxDistance: f.MaxDistance,
}
- } else if paths, ok := relPaths(zk, filtering.NoLinkedBy); ok {
- opts.LinkedBy = ¬e.LinkFilter{
+ } else if paths, ok := relPaths(notebook, f.NoLinkedBy); ok {
+ opts.LinkedBy = &core.LinkFilter{
Paths: paths,
Negate: true,
}
}
- if paths, ok := relPaths(zk, filtering.LinkTo); ok {
- opts.LinkTo = ¬e.LinkFilter{
+ if paths, ok := relPaths(notebook, f.LinkTo); ok {
+ opts.LinkTo = &core.LinkFilter{
Paths: paths,
Negate: false,
- Recursive: filtering.Recursive,
- MaxDistance: filtering.MaxDistance,
+ Recursive: f.Recursive,
+ MaxDistance: f.MaxDistance,
}
- } else if paths, ok := relPaths(zk, filtering.NoLinkTo); ok {
- opts.LinkTo = ¬e.LinkFilter{
+ } else if paths, ok := relPaths(notebook, f.NoLinkTo); ok {
+ opts.LinkTo = &core.LinkFilter{
Paths: paths,
Negate: true,
}
}
- if paths, ok := relPaths(zk, filtering.Related); ok {
+ if paths, ok := relPaths(notebook, f.Related); ok {
opts.Related = paths
}
- opts.Orphan = filtering.Orphan
+ opts.Orphan = f.Orphan
- if filtering.Created != "" {
- start, end, err := parseDayRange(filtering.Created)
+ if f.Created != "" {
+ start, end, err := parseDayRange(f.Created)
if err != nil {
- return nil, err
+ return opts, err
}
opts.CreatedStart = &start
opts.CreatedEnd = &end
} else {
- if filtering.CreatedBefore != "" {
- date, err := parseDate(filtering.CreatedBefore)
+ if f.CreatedBefore != "" {
+ date, err := parseDate(f.CreatedBefore)
if err != nil {
- return nil, err
+ return opts, err
}
opts.CreatedEnd = &date
}
- if filtering.CreatedAfter != "" {
- date, err := parseDate(filtering.CreatedAfter)
+ if f.CreatedAfter != "" {
+ date, err := parseDate(f.CreatedAfter)
if err != nil {
- return nil, err
+ return opts, err
}
opts.CreatedStart = &date
}
}
- if filtering.Modified != "" {
- start, end, err := parseDayRange(filtering.Modified)
+ if f.Modified != "" {
+ start, end, err := parseDayRange(f.Modified)
if err != nil {
- return nil, err
+ return opts, err
}
opts.ModifiedStart = &start
opts.ModifiedEnd = &end
} else {
- if filtering.ModifiedBefore != "" {
- date, err := parseDate(filtering.ModifiedBefore)
+ if f.ModifiedBefore != "" {
+ date, err := parseDate(f.ModifiedBefore)
if err != nil {
- return nil, err
+ return opts, err
}
opts.ModifiedEnd = &date
}
- if filtering.ModifiedAfter != "" {
- date, err := parseDate(filtering.ModifiedAfter)
+ if f.ModifiedAfter != "" {
+ date, err := parseDate(f.ModifiedAfter)
if err != nil {
- return nil, err
+ return opts, err
}
opts.ModifiedStart = &date
}
}
- opts.Interactive = filtering.Interactive
-
- sorters, err := note.SortersFromStrings(filtering.Sort)
+ sorters, err := core.NoteSortersFromStrings(f.Sort)
if err != nil {
- return nil, err
+ return opts, err
}
opts.Sorters = sorters
- opts.Limit = filtering.Limit
+ opts.Limit = f.Limit
- return &opts, nil
+ return opts, nil
}
-func relPaths(zk *zk.Zk, paths []string) ([]string, bool) {
+func relPaths(notebook *core.Notebook, paths []string) ([]string, bool) {
relPaths := make([]string, 0)
for _, p := range paths {
- path, err := zk.RelPath(p)
+ path, err := notebook.RelPath(p)
if err == nil {
relPaths = append(relPaths, path)
}
diff --git a/cmd/finder_opts_test.go b/internal/cli/filtering_test.go
similarity index 98%
rename from cmd/finder_opts_test.go
rename to internal/cli/filtering_test.go
index 929069d..d9c703e 100644
--- a/cmd/finder_opts_test.go
+++ b/internal/cli/filtering_test.go
@@ -1,9 +1,9 @@
-package cmd
+package cli
import (
"testing"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestExpandNamedFiltersNone(t *testing.T) {
diff --git a/internal/core/collection.go b/internal/core/collection.go
new file mode 100644
index 0000000..3b80acb
--- /dev/null
+++ b/internal/core/collection.go
@@ -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
+}
diff --git a/core/zk/config.go b/internal/core/config.go
similarity index 81%
rename from core/zk/config.go
rename to internal/core/config.go
index 17b5277..d59e1a2 100644
--- a/core/zk/config.go
+++ b/internal/core/config.go
@@ -1,16 +1,15 @@
-package zk
+package core
import (
- "io/ioutil"
+ "fmt"
"path/filepath"
- "github.com/mickael-menu/zk/util/errors"
- "github.com/mickael-menu/zk/util/opt"
- "github.com/mickael-menu/zk/util/paths"
+ "github.com/mickael-menu/zk/internal/util/errors"
+ "github.com/mickael-menu/zk/internal/util/opt"
toml "github.com/pelletier/go-toml"
)
-// Config holds the global user configuration.
+// Config holds the user configuration.
type Config struct {
Note NoteConfig
Groups map[string]GroupConfig
@@ -19,8 +18,6 @@ type Config struct {
Filters map[string]string
Aliases map[string]string
Extra map[string]string
- // Base directories for the relative template paths used in NoteConfig.
- TemplatesDirs []string
}
// NewDefaultConfig creates a new Config with the default settings.
@@ -46,10 +43,9 @@ func NewDefaultConfig() Config {
MultiwordTags: false,
},
},
- Filters: map[string]string{},
- Aliases: map[string]string{},
- Extra: map[string]string{},
- TemplatesDirs: []string{},
+ Filters: map[string]string{},
+ Aliases: map[string]string{},
+ Extra: map[string]string{},
}
}
@@ -62,29 +58,35 @@ func (c Config) RootGroupConfig() GroupConfig {
}
}
-// LocateTemplate returns the absolute path for the given template path, by
-// looking for it in the templates directories registered in this Config.
-func (c Config) LocateTemplate(path string) (string, bool) {
- if path == "" {
- return "", false
- }
-
- exists := func(path string) bool {
- exists, err := paths.Exists(path)
- return exists && err == nil
- }
-
- if filepath.IsAbs(path) {
- return path, exists(path)
+// GroupConfigNamed returns the GroupConfig for the group with the given name.
+// An empty name matches the root GroupConfig.
+func (c Config) GroupConfigNamed(name string) (GroupConfig, error) {
+ if name == "" {
+ return c.RootGroupConfig(), nil
+ } else {
+ group, ok := c.Groups[name]
+ if !ok {
+ return GroupConfig{}, fmt.Errorf("no group named `%s` found in the config", name)
+ }
+ return group, nil
}
+}
- for _, dir := range c.TemplatesDirs {
- if candidate := filepath.Join(dir, path); exists(candidate) {
- return candidate, true
+// GroupNameForPath returns the name of the GroupConfig matching the given
+// path, relative to the notebook.
+func (c Config) GroupNameForPath(path string) (string, error) {
+ for name, config := range c.Groups {
+ for _, groupPath := range config.Paths {
+ matches, err := filepath.Match(groupPath, path)
+ if err != nil {
+ return "", errors.Wrapf(err, "failed to match group %s to %s", name, path)
+ } else if matches {
+ return name, nil
+ }
}
}
- return path, false
+ return "", nil
}
// FormatConfig holds the configuration for document formats, such as Markdown.
@@ -132,14 +134,6 @@ type GroupConfig struct {
Extra map[string]string
}
-// ConfigOverrides holds user configuration overridden values, for example fed
-// from CLI flags.
-type ConfigOverrides struct {
- Group opt.String
- BodyTemplatePath opt.String
- Extra map[string]string
-}
-
// Clone creates a copy of the GroupConfig receiver.
func (c GroupConfig) Clone() GroupConfig {
clone := c
@@ -154,29 +148,16 @@ func (c GroupConfig) Clone() GroupConfig {
return clone
}
-// Override modifies the GroupConfig receiver by updating the properties
-// overridden in ConfigOverrides.
-func (c *GroupConfig) Override(overrides ConfigOverrides) {
- if !overrides.BodyTemplatePath.IsNull() {
- c.Note.BodyTemplatePath = overrides.BodyTemplatePath
- }
- if overrides.Extra != nil {
- for k, v := range overrides.Extra {
- c.Extra[k] = v
- }
- }
-}
-
// OpenConfig creates a new Config instance from its TOML representation stored
// in the given file.
-func OpenConfig(path string, parentConfig Config) (Config, error) {
+func OpenConfig(path string, parentConfig Config, fs FileStorage) (Config, error) {
// The local config is optional.
- exists, err := paths.Exists(path)
+ exists, err := fs.FileExists(path)
if err == nil && !exists {
return parentConfig, nil
}
- content, err := ioutil.ReadFile(path)
+ content, err := fs.Read(path)
if err != nil {
return parentConfig, errors.Wrapf(err, "failed to open config file at %s", path)
}
@@ -278,8 +259,6 @@ func ParseConfig(content []byte, path string, parentConfig Config) (Config, erro
}
}
- config.TemplatesDirs = append([]string{filepath.Join(filepath.Dir(path), "templates")}, config.TemplatesDirs...)
-
return config, nil
}
diff --git a/core/zk/config_test.go b/internal/core/config_test.go
similarity index 78%
rename from core/zk/config_test.go
rename to internal/core/config_test.go
index 28ccbc5..cae645f 100644
--- a/core/zk/config_test.go
+++ b/internal/core/config_test.go
@@ -1,15 +1,12 @@
-package zk
+package core
import (
"fmt"
- "os"
- "path/filepath"
"testing"
- "time"
"github.com/google/go-cmp/cmp"
- "github.com/mickael-menu/zk/util/opt"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestParseDefaultConfig(t *testing.T) {
@@ -42,10 +39,9 @@ func TestParseDefaultConfig(t *testing.T) {
Pager: opt.NullString,
FzfPreview: opt.NullString,
},
- Filters: make(map[string]string),
- Aliases: make(map[string]string),
- Extra: make(map[string]string),
- TemplatesDirs: []string{".zk/templates"},
+ Filters: make(map[string]string),
+ Aliases: make(map[string]string),
+ Extra: make(map[string]string),
})
}
@@ -211,7 +207,6 @@ func TestParseComplete(t *testing.T) {
"hello": "world",
"salut": "le monde",
},
- TemplatesDirs: []string{".zk/templates"},
})
}
@@ -313,7 +308,6 @@ func TestParseMergesGroupConfig(t *testing.T) {
"hello": "world",
"salut": "le monde",
},
- TemplatesDirs: []string{".zk/templates"},
})
}
@@ -373,37 +367,6 @@ func TestParseIDCase(t *testing.T) {
test("unknown", CaseLower)
}
-func TestLocateTemplate(t *testing.T) {
- root := fmt.Sprintf("/tmp/zk-test-%d", time.Now().Unix())
- os.Remove(root)
- os.MkdirAll(filepath.Join(root, "templates"), os.ModePerm)
-
- test := func(template string, expected string, exists bool) {
- conf, err := ParseConfig([]byte(""), filepath.Join(root, "config.toml"), NewDefaultConfig())
- assert.Nil(t, err)
-
- path, ok := conf.LocateTemplate(template)
- if exists {
- assert.True(t, ok)
- if path != expected {
- t.Errorf("Didn't resolve template `%v` as expected: %v", template, expected)
- }
- } else {
- assert.False(t, ok)
- }
- }
-
- tpl1 := filepath.Join(root, "templates/template.tpl")
- test("template.tpl", tpl1, false)
- os.Create(tpl1)
- test("template.tpl", tpl1, true)
-
- tpl2 := filepath.Join(root, "abs.tpl")
- test(tpl2, tpl2, false)
- os.Create(tpl2)
- test(tpl2, tpl2, true)
-}
-
func TestGroupConfigClone(t *testing.T) {
original := GroupConfig{
Paths: []string{"original"},
@@ -459,67 +422,3 @@ func TestGroupConfigClone(t *testing.T) {
},
})
}
-
-func TestGroupConfigOverride(t *testing.T) {
- sut := GroupConfig{
- Paths: []string{"path"},
- Note: NoteConfig{
- FilenameTemplate: "filename",
- BodyTemplatePath: opt.NewString("body.tpl"),
- IDOptions: IDOptions{
- Length: 4,
- Charset: CharsetLetters,
- Case: CaseUpper,
- },
- },
- Extra: map[string]string{
- "hello": "world",
- "salut": "le monde",
- },
- }
-
- // Empty overrides
- sut.Override(ConfigOverrides{})
- assert.Equal(t, sut, GroupConfig{
- Paths: []string{"path"},
- Note: NoteConfig{
- FilenameTemplate: "filename",
- BodyTemplatePath: opt.NewString("body.tpl"),
- IDOptions: IDOptions{
- Length: 4,
- Charset: CharsetLetters,
- Case: CaseUpper,
- },
- },
- Extra: map[string]string{
- "hello": "world",
- "salut": "le monde",
- },
- })
-
- // Some overrides
- sut.Override(ConfigOverrides{
- BodyTemplatePath: opt.NewString("overridden-template"),
- Extra: map[string]string{
- "hello": "overridden",
- "additional": "value",
- },
- })
- assert.Equal(t, sut, GroupConfig{
- Paths: []string{"path"},
- Note: NoteConfig{
- FilenameTemplate: "filename",
- BodyTemplatePath: opt.NewString("overridden-template"),
- IDOptions: IDOptions{
- Length: 4,
- Charset: CharsetLetters,
- Case: CaseUpper,
- },
- },
- Extra: map[string]string{
- "hello": "overridden",
- "salut": "le monde",
- "additional": "value",
- },
- })
-}
diff --git a/internal/core/fs.go b/internal/core/fs.go
new file mode 100644
index 0000000..4810edc
--- /dev/null
+++ b/internal/core/fs.go
@@ -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
+}
diff --git a/internal/core/fs_test.go b/internal/core/fs_test.go
new file mode 100644
index 0000000..bdf9980
--- /dev/null
+++ b/internal/core/fs_test.go
@@ -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
+}
diff --git a/core/zk/id.go b/internal/core/id.go
similarity index 75%
rename from core/zk/id.go
rename to internal/core/id.go
index 77f5ed6..c85b321 100644
--- a/core/zk/id.go
+++ b/internal/core/id.go
@@ -1,4 +1,4 @@
-package zk
+package core
// IDOptions holds the options used to generate an ID.
type IDOptions struct {
@@ -29,3 +29,9 @@ const (
CaseUpper
CaseMixed
)
+
+// IDGenerator is a function returning a new ID with each invocation.
+type IDGenerator func() string
+
+// IDGeneratorFactory creates a new IDGenerator function using the given IDOptions.
+type IDGeneratorFactory func(opts IDOptions) func() string
diff --git a/internal/core/link.go b/internal/core/link.go
new file mode 100644
index 0000000..e8a1bdd
--- /dev/null
+++ b/internal/core/link.go
@@ -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
+}
diff --git a/internal/core/note.go b/internal/core/note.go
new file mode 100644
index 0000000..5a31922
--- /dev/null
+++ b/internal/core/note.go
@@ -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
+}
diff --git a/core/note/find.go b/internal/core/note_find.go
similarity index 55%
rename from core/note/find.go
rename to internal/core/note_find.go
index f7c473b..78e93a1 100644
--- a/core/note/find.go
+++ b/internal/core/note_find.go
@@ -1,29 +1,16 @@
-package note
+package core
import (
- "errors"
"fmt"
"strings"
"time"
"unicode/utf8"
- "github.com/mickael-menu/zk/core"
- "github.com/mickael-menu/zk/util/opt"
+ "github.com/mickael-menu/zk/internal/util/opt"
)
-// ErrCanceled is returned when the user cancelled an operation.
-var ErrCanceled = errors.New("canceled")
-
-// Finder retrieves notes matching the given options.
-//
-// Returns the number of matches found.
-type Finder interface {
- Find(opts FinderOpts) ([]Match, error)
- FindByHref(href string) (*Match, error)
-}
-
-// FinderOpts holds the option used to filter and order a list of notes.
-type FinderOpts struct {
+// NoteFindOpts holds a set of filtering options used to find notes.
+type NoteFindOpts struct {
// Filter used to match the notes with FTS predicates.
Match opt.String
// Filter by note paths.
@@ -31,7 +18,7 @@ type FinderOpts struct {
// Filter excluding notes at the given paths.
ExcludePaths []string
// Filter excluding notes with the given IDs.
- ExcludeIds []core.NoteId
+ ExcludeIDs []NoteID
// Filter by tags found in the notes.
Tags []string
// Filter the notes mentioning the given ones.
@@ -54,32 +41,23 @@ type FinderOpts struct {
ModifiedStart *time.Time
// Filter notes modified before the given date.
ModifiedEnd *time.Time
- // Indicates that the user should select manually the notes.
- Interactive bool
// Limits the number of results
Limit int
// Sorting criteria
- Sorters []Sorter
+ Sorters []NoteSorter
}
-// ExcludingIds creates a new FinderOpts after adding the given ids to the list
-// of excluded note ids.
-func (o FinderOpts) ExcludingIds(ids ...core.NoteId) FinderOpts {
- if o.ExcludeIds == nil {
- o.ExcludeIds = []core.NoteId{}
+// ExcludingID creates a new FinderOpts after adding the given ID to the list
+// of excluded note IDs.
+func (o NoteFindOpts) ExcludingID(id NoteID) NoteFindOpts {
+ if o.ExcludeIDs == nil {
+ o.ExcludeIDs = []NoteID{}
}
- o.ExcludeIds = append(o.ExcludeIds, ids...)
+ o.ExcludeIDs = append(o.ExcludeIDs, id)
return o
}
-// Match holds information about a note matching the find options.
-type Match struct {
- Metadata
- // Snippets are relevant excerpts in the note.
- Snippets []string
-}
-
// LinkFilter is a note filter used to select notes linking to other ones.
type LinkFilter struct {
Paths []string
@@ -88,53 +66,74 @@ type LinkFilter struct {
MaxDistance int
}
-// Sorter represents an order term used to sort a list of notes.
-type Sorter struct {
- Field SortField
+// NoteSorter represents an order term used to sort a list of notes.
+type NoteSorter struct {
+ Field NoteSortField
Ascending bool
}
-// SortField represents a note field used to sort a list of notes.
-type SortField int
+// NoteSortField represents a note field used to sort a list of notes.
+type NoteSortField int
const (
// Sort by creation date.
- SortCreated SortField = iota + 1
+ NoteSortCreated NoteSortField = iota + 1
// Sort by modification date.
- SortModified
+ NoteSortModified
// Sort by the file paths.
- SortPath
+ NoteSortPath
// Sort randomly.
- SortRandom
+ NoteSortRandom
// Sort by the note titles.
- SortTitle
+ NoteSortTitle
// Sort by the number of words in the note bodies.
- SortWordCount
+ NoteSortWordCount
+ // Sort by the length of the note path.
+ // This is not accessible to the user but used for technical reasons, to
+ // find the best match when searching a path prefix.
+ NoteSortPathLength
)
-// SorterFromString returns a Sorter from its string representation.
+// NoteSortersFromStrings returns a list of NoteSorter from their string
+// representation.
+func NoteSortersFromStrings(strs []string) ([]NoteSorter, error) {
+ sorters := make([]NoteSorter, 0)
+
+ // Iterates in reverse order to be able to override sort criteria set in a
+ // config alias with a `--sort` flag.
+ for i := len(strs) - 1; i >= 0; i-- {
+ sorter, err := NoteSorterFromString(strs[i])
+ if err != nil {
+ return sorters, err
+ }
+ sorters = append(sorters, sorter)
+ }
+ return sorters, nil
+}
+
+// NoteSorterFromString returns a NoteSorter from its string representation.
//
// If the input str has for suffix `+`, then the order will be ascending, while
// descending for `-`. If no suffix is given, then the default order for the
// sorting field will be used.
-func SorterFromString(str string) (Sorter, error) {
+func NoteSorterFromString(str string) (NoteSorter, error) {
orderSymbol, _ := utf8.DecodeLastRuneInString(str)
str = strings.TrimRight(str, "+-")
- var sorter Sorter
+ var sorter NoteSorter
switch str {
case "created", "c":
- sorter = Sorter{Field: SortCreated, Ascending: false}
+ sorter = NoteSorter{Field: NoteSortCreated, Ascending: false}
case "modified", "m":
- sorter = Sorter{Field: SortModified, Ascending: false}
+ sorter = NoteSorter{Field: NoteSortModified, Ascending: false}
case "path", "p":
- sorter = Sorter{Field: SortPath, Ascending: true}
+ sorter = NoteSorter{Field: NoteSortPath, Ascending: true}
case "title", "t":
- sorter = Sorter{Field: SortTitle, Ascending: true}
+ sorter = NoteSorter{Field: NoteSortTitle, Ascending: true}
case "random", "r":
- sorter = Sorter{Field: SortRandom, Ascending: true}
+ sorter = NoteSorter{Field: NoteSortRandom, Ascending: true}
case "word-count", "wc":
- sorter = Sorter{Field: SortWordCount, Ascending: true}
+ sorter = NoteSorter{Field: NoteSortWordCount, Ascending: true}
default:
return sorter, fmt.Errorf("%s: unknown sorting term\ntry created, modified, path, title, random or word-count", str)
}
@@ -148,19 +147,3 @@ func SorterFromString(str string) (Sorter, error) {
return sorter, nil
}
-
-// SortersFromStrings returns a list of Sorter from their string representation.
-func SortersFromStrings(strs []string) ([]Sorter, error) {
- sorters := make([]Sorter, 0)
-
- // Iterates in reverse order to be able to override sort criteria set in a
- // config alias with a `--sort` flag.
- for i := len(strs) - 1; i >= 0; i-- {
- sorter, err := SorterFromString(strs[i])
- if err != nil {
- return sorters, err
- }
- sorters = append(sorters, sorter)
- }
- return sorters, nil
-}
diff --git a/internal/core/note_find_test.go b/internal/core/note_find_test.go
new file mode 100644
index 0000000..cc349f2
--- /dev/null
+++ b/internal/core/note_find_test.go
@@ -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")
+}
diff --git a/internal/core/note_format.go b/internal/core/note_format.go
new file mode 100644
index 0000000..b99fe20
--- /dev/null
+++ b/internal/core/note_format.go
@@ -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(`(.*?)`)
+
+// 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
+}
diff --git a/internal/core/note_format_test.go b/internal/core/note_format_test.go
new file mode 100644
index 0000000..2f8cac0
--- /dev/null
+++ b/internal/core/note_format_test.go
@@ -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 world!", "Hello term(world)!")
+ test("Hello world with several matches!", "Hello term(world) with term(several matches)!")
+ test("Hello world with several matches!", "Hello term(world) with term(several 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)
+}
diff --git a/internal/core/note_index.go b/internal/core/note_index.go
new file mode 100644
index 0000000..6b9e31e
--- /dev/null
+++ b/internal/core/note_index.go
@@ -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()
+}
diff --git a/internal/core/note_new.go b/internal/core/note_new.go
new file mode 100644
index 0000000..80a28b1
--- /dev/null
+++ b/internal/core/note_new.go
@@ -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
+}
diff --git a/internal/core/note_new_test.go b/internal/core/note_new_test.go
new file mode 100644
index 0000000..89baa3a
--- /dev/null
+++ b/internal/core/note_new_test.go
@@ -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)
+ }
+}
diff --git a/internal/core/note_parse.go b/internal/core/note_parse.go
new file mode 100644
index 0000000..fbcf091
--- /dev/null
+++ b/internal/core/note_parse.go
@@ -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{}
+}
diff --git a/internal/core/notebook.go b/internal/core/notebook.go
new file mode 100644
index 0000000..31fa456
--- /dev/null
+++ b/internal/core/notebook.go
@@ -0,0 +1,295 @@
+package core
+
+import (
+ "fmt"
+ "os"
+ "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/opt"
+ "github.com/mickael-menu/zk/internal/util/paths"
+ "github.com/schollz/progressbar/v3"
+)
+
+// Notebook handles queries and commands performed on an opened notebook.
+type Notebook struct {
+ Path string
+ Config Config
+
+ index NoteIndex
+ parser NoteParser
+ templateLoaderFactory TemplateLoaderFactory
+ idGeneratorFactory IDGeneratorFactory
+ fs FileStorage
+ logger util.Logger
+ osEnv func() map[string]string
+}
+
+// NewNotebook creates a new Notebook instance.
+func NewNotebook(
+ path string,
+ config Config,
+ ports NotebookPorts,
+) *Notebook {
+ return &Notebook{
+ Path: path,
+ Config: config,
+ index: ports.NoteIndex,
+ parser: ports.NoteParser,
+ templateLoaderFactory: ports.TemplateLoaderFactory,
+ idGeneratorFactory: ports.IDGeneratorFactory,
+ fs: ports.FS,
+ logger: ports.Logger,
+ osEnv: ports.OSEnv,
+ }
+}
+
+type NotebookPorts struct {
+ NoteIndex NoteIndex
+ NoteParser NoteParser
+ TemplateLoaderFactory TemplateLoaderFactory
+ IDGeneratorFactory IDGeneratorFactory
+ FS FileStorage
+ Logger util.Logger
+ OSEnv func() map[string]string
+}
+
+// NotebookFactory creates a new Notebook instance at the given root path.
+type NotebookFactory func(path string, config Config) (*Notebook, error)
+
+// Index indexes the content of the notebook to be searchable.
+// If force is true, existing notes will be reindexed.
+func (n *Notebook) Index(force bool) (stats NoteIndexingStats, err error) {
+ // FIXME: Move out of Core
+ bar := progressbar.NewOptions(-1,
+ progressbar.OptionSetWriter(os.Stderr),
+ progressbar.OptionThrottle(100*time.Millisecond),
+ progressbar.OptionSpinnerType(14),
+ )
+
+ err = n.index.Commit(func(index NoteIndex) error {
+ task := indexTask{
+ notebook: n,
+ force: force,
+ index: index,
+ parser: n.parser,
+ logger: n.logger,
+ }
+ stats, err = task.execute(func(change paths.DiffChange) {
+ bar.Add(1)
+ bar.Describe(change.String())
+ })
+ return err
+ })
+
+ bar.Clear()
+ err = errors.Wrap(err, "indexing")
+ return
+}
+
+// NewNoteOpts holds the options used to create a new note in a Notebook.
+type NewNoteOpts struct {
+ // Title of the new note.
+ Title opt.String
+ // Initial content of the note.
+ Content string
+ // Directory in which to create the note, relative to the root of the notebook.
+ Directory opt.String
+ // Group this note belongs to.
+ Group opt.String
+ // Path to a custom template used to render the note.
+ Template opt.String
+ // Extra variables passed to the templates.
+ Extra map[string]string
+ // Creation date provided to the templates.
+ Date time.Time
+}
+
+// ErrNoteExists is an error returned when a note already exists with the
+// generated filename.
+type ErrNoteExists struct {
+ Name string
+ Path string
+}
+
+func (e ErrNoteExists) Error() string {
+ return fmt.Sprintf("%s: note already exists", e.Path)
+}
+
+// NewNote generates a new note in the notebook and returns its path.
+//
+// Returns ErrNoteExists if no free filename can be generated for this note.
+func (n *Notebook) NewNote(opts NewNoteOpts) (string, error) {
+ wrap := errors.Wrapper("new note")
+
+ dir, err := n.RequireDirAt(opts.Directory.OrString(n.Path).Unwrap())
+ if err != nil {
+ return "", wrap(err)
+ }
+
+ config, err := n.Config.GroupConfigNamed(opts.Group.OrString(dir.Group).Unwrap())
+ if err != nil {
+ return "", wrap(err)
+ }
+
+ extra := config.Extra
+ for k, v := range opts.Extra {
+ extra[k] = v
+ }
+
+ templates, err := n.templateLoaderFactory(config.Note.Lang)
+ if err != nil {
+ return "", wrap(err)
+ }
+
+ task := newNoteTask{
+ dir: dir,
+ title: opts.Title.OrString(config.Note.DefaultTitle).Unwrap(),
+ content: opts.Content,
+ date: opts.Date,
+ extra: extra,
+ env: n.osEnv(),
+ fs: n.fs,
+ filenameTemplate: config.Note.FilenameTemplate + "." + config.Note.Extension,
+ bodyTemplatePath: opts.Template.Or(config.Note.BodyTemplatePath),
+ templates: templates,
+ genID: n.idGeneratorFactory(config.Note.IDOptions),
+ }
+ path, err := task.execute()
+ return path, wrap(err)
+}
+
+// FindNotes retrieves the notes matching the given filtering options.
+func (n *Notebook) FindNotes(opts NoteFindOpts) ([]ContextualNote, error) {
+ return n.index.Find(opts)
+}
+
+// FindMinimalNotes retrieves lightweight metadata for the notes matching
+// the given filtering options.
+func (n *Notebook) FindMinimalNotes(opts NoteFindOpts) ([]MinimalNote, error) {
+ return n.index.FindMinimal(opts)
+}
+
+// FindByHref retrieves the first note matching the given link href.
+func (n *Notebook) FindByHref(href string) (*MinimalNote, error) {
+ notes, err := n.FindMinimalNotes(NoteFindOpts{
+ IncludePaths: []string{href},
+ Limit: 1,
+ // To find the best match possible, we sort by path length.
+ // See https://github.com/mickael-menu/zk/issues/23
+ Sorters: []NoteSorter{{Field: NoteSortPathLength, Ascending: true}},
+ })
+
+ switch {
+ case err != nil:
+ return nil, err
+ case len(notes) == 0:
+ return nil, nil
+ default:
+ return ¬es[0], nil
+ }
+}
+
+// FindCollections retrieves all the collections of the given kind.
+func (n *Notebook) FindCollections(kind CollectionKind) ([]Collection, error) {
+ return n.index.FindCollections(kind)
+}
+
+// RelPath returns the path relative to the notebook root to the given path.
+func (n *Notebook) RelPath(originalPath string) (string, error) {
+ wrap := errors.Wrapperf("%v: not a valid notebook path", originalPath)
+
+ path, err := n.fs.Abs(originalPath)
+ if err != nil {
+ return path, wrap(err)
+ }
+
+ path, err = filepath.Rel(n.Path, path)
+ if err != nil {
+ return path, wrap(err)
+ }
+ if strings.HasPrefix(path, "..") {
+ return path, fmt.Errorf("%s: path is outside the notebook", originalPath)
+ }
+ if path == "." {
+ path = ""
+ }
+ return path, nil
+}
+
+// Dir represents a directory inside a notebook.
+type Dir struct {
+ // Name of the directory, which is the path relative to the notebook's root.
+ Name string
+ // Absolute path to the directory.
+ Path string
+ // Name of the config group this directory belongs to, if any.
+ Group string
+}
+
+// RootDir returns the root directory for this notebook.
+func (n *Notebook) RootDir() Dir {
+ return Dir{
+ Name: "",
+ Path: n.Path,
+ Group: "",
+ }
+}
+
+// DirAt returns a Dir representation of the notebook directory at the given path.
+func (n *Notebook) DirAt(path string) (Dir, error) {
+ path, err := n.fs.Abs(path)
+ if err != nil {
+ return Dir{}, err
+ }
+
+ name, err := n.RelPath(path)
+ if err != nil {
+ return Dir{}, err
+ }
+
+ group, err := n.Config.GroupNameForPath(name)
+ if err != nil {
+ return Dir{}, err
+ }
+
+ return Dir{
+ Name: name,
+ Path: path,
+ Group: group,
+ }, nil
+}
+
+// RequireDirAt is the same as DirAt, but checks that the directory exists
+// before returning the Dir.
+func (n *Notebook) RequireDirAt(path string) (Dir, error) {
+ dir, err := n.DirAt(path)
+ if err != nil {
+ return dir, err
+ }
+ exists, err := n.fs.DirExists(dir.Path)
+ if err != nil {
+ return dir, err
+ }
+ if !exists {
+ return dir, fmt.Errorf("%v: directory not found", path)
+ }
+ return dir, nil
+}
+
+// NewNoteFormatter returns a NoteFormatter used to format notes with the given template.
+func (n *Notebook) NewNoteFormatter(templateString string) (NoteFormatter, error) {
+ templates, err := n.templateLoaderFactory(n.Config.Note.Lang)
+ if err != nil {
+ return nil, err
+ }
+ template, err := templates.LoadTemplate(templateString)
+ if err != nil {
+ return nil, err
+ }
+
+ return newNoteFormatter(n.Path, template, n.fs)
+}
diff --git a/core/zk/zk.go b/internal/core/notebook_store.go
similarity index 54%
rename from core/zk/zk.go
rename to internal/core/notebook_store.go
index cbc907d..425c922 100644
--- a/core/zk/zk.go
+++ b/internal/core/notebook_store.go
@@ -1,14 +1,38 @@
-package zk
+package core
import (
"fmt"
"path/filepath"
- "strings"
- "github.com/mickael-menu/zk/util/errors"
- "github.com/mickael-menu/zk/util/paths"
+ "github.com/mickael-menu/zk/internal/util/errors"
)
+// NotebookStore retrieves or creates new notebooks.
+type NotebookStore struct {
+ config Config
+ notebookFactory NotebookFactory
+ fs FileStorage
+
+ // Cached opened notebooks.
+ notebooks map[string]*Notebook
+}
+
+type NotebookStorePorts struct {
+ NotebookFactory NotebookFactory
+ FS FileStorage
+}
+
+// NewNotebookStore creates a new NotebookStore instance using the given
+// options and port implementations.
+func NewNotebookStore(config Config, ports NotebookStorePorts) *NotebookStore {
+ return &NotebookStore{
+ config: config,
+ notebookFactory: ports.NotebookFactory,
+ fs: ports.FS,
+ notebooks: map[string]*Notebook{},
+ }
+}
+
// ErrNotebookNotFound is an error returned when a notebook cannot be found at the given path or its parents.
type ErrNotebookNotFound string
@@ -16,6 +40,110 @@ func (e ErrNotebookNotFound) Error() string {
return fmt.Sprintf("no notebook found in %s or a parent directory", string(e))
}
+// Open returns a new Notebook instance for the notebook containing the
+// given file path.
+func (ns *NotebookStore) Open(path string) (*Notebook, error) {
+ wrap := errors.Wrapper("open failed")
+
+ path = ns.fs.Canonical(path)
+ nb := ns.cachedNotebookAt(path)
+ if nb != nil {
+ return nb, nil
+ }
+
+ path, err := ns.fs.Abs(path)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ path, err = ns.locateNotebook(path)
+ if err != nil {
+ return nil, wrap(err)
+ }
+
+ configPath := filepath.Join(path, ".zk/config.toml")
+ config, err := OpenConfig(configPath, ns.config, ns.fs)
+ if err != nil {
+ return nil, wrap(err)
+ }
+
+ nb, err = ns.notebookFactory(path, config)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ ns.notebooks[path] = nb
+
+ return nb, nil
+}
+
+// cachedNotebookAt returns any cached notebook containing the given path.
+func (ns *NotebookStore) cachedNotebookAt(path string) *Notebook {
+ path, err := ns.fs.Abs(path)
+ if err != nil {
+ return nil
+ }
+
+ for root, nb := range ns.notebooks {
+ if isDesc, err := ns.fs.IsDescendantOf(root, path); isDesc && err == nil {
+ return nb
+ }
+ }
+
+ return nil
+}
+
+// Init creates a new notebook at the given file path.
+func (ns *NotebookStore) Init(path string) (*Notebook, error) {
+ wrap := errors.Wrapper("init")
+
+ path, err := ns.fs.Abs(path)
+ if err != nil {
+ return nil, wrap(err)
+ }
+
+ if existingPath, err := ns.locateNotebook(path); err == nil {
+ return nil, wrap(fmt.Errorf("a notebook already exists in %v", existingPath))
+ }
+
+ // Create the default configuration file.
+ err = ns.fs.Write(filepath.Join(path, ".zk/config.toml"), []byte(defaultConfig))
+ if err != nil {
+ return nil, wrap(err)
+ }
+
+ // Create the default template.
+ err = ns.fs.Write(filepath.Join(path, ".zk/templates/default.md"), []byte(defaultTemplate))
+ if err != nil {
+ return nil, wrap(err)
+ }
+
+ return ns.Open(path)
+}
+
+// locateNotebook finds the root of the notebook containing the given path.
+func (ns *NotebookStore) locateNotebook(path string) (string, error) {
+ if !filepath.IsAbs(path) {
+ panic("absolute path expected")
+ }
+
+ var locate func(string) (string, error)
+ locate = func(currentPath string) (string, error) {
+ if currentPath == "/" || currentPath == "." {
+ return "", ErrNotebookNotFound(path)
+ }
+ exists, err := ns.fs.DirExists(filepath.Join(currentPath, ".zk"))
+ switch {
+ case err != nil:
+ return "", err
+ case exists:
+ return currentPath, nil
+ default:
+ return locate(filepath.Dir(currentPath))
+ }
+ }
+
+ return locate(path)
+}
+
const defaultConfig = `# zk configuration file
#
# Uncomment the properties you want to customize.
@@ -80,11 +208,11 @@ template = "default.md"
# the group. This can be useful to quickly declare a group by the name of the
# directory it applies to.
-#[dir.""]
+#[group.""]
#paths = ["", ""]
-#[dir."".note]
+#[group."".note]
#filename = "{{date now}}"
-#[dir."".extra]
+#[group."".extra]
#key = "value"
@@ -176,230 +304,3 @@ const defaultTemplate = `# {{title}}
{{content}}
`
-
-// Zk (Zettelkasten) represents an opened notebook.
-type Zk struct {
- // Notebook root path.
- Path string
- // Global user configuration.
- Config Config
- // Working directory from which paths are relative.
- workingDir string
-}
-
-// Dir represents a directory inside a notebook.
-type Dir struct {
- // Name of the directory, which is the path relative to the notebook's root.
- Name string
- // Absolute path to the directory.
- Path string
- // User configuration for this directory.
- Config GroupConfig
-}
-
-// Open locates a notebook at the given path and parses its configuration.
-func Open(originalPath string, parentConfig Config) (*Zk, error) {
- wrap := errors.Wrapper("open failed")
-
- path, err := filepath.Abs(originalPath)
- if err != nil {
- return nil, wrap(err)
- }
- path, err = locateRoot(path)
- if err != nil {
- return nil, wrap(err)
- }
-
- config, err := OpenConfig(filepath.Join(path, ".zk/config.toml"), parentConfig)
- if err != nil {
- return nil, wrap(err)
- }
-
- return &Zk{
- Path: path,
- Config: config,
- workingDir: originalPath,
- }, nil
-}
-
-// Create initializes a new notebook at the given path.
-func Create(path string) error {
- wrap := errors.Wrapper("init failed")
-
- path, err := filepath.Abs(path)
- if err != nil {
- return wrap(err)
- }
-
- if existingPath, err := locateRoot(path); err == nil {
- return wrap(fmt.Errorf("a notebook already exists in %v", existingPath))
- }
-
- // Write default config.toml.
- err = paths.WriteString(filepath.Join(path, ".zk/config.toml"), defaultConfig)
- if err != nil {
- return wrap(err)
- }
-
- // Write default template.
- err = paths.WriteString(filepath.Join(path, ".zk/templates/default.md"), defaultTemplate)
- if err != nil {
- return wrap(err)
- }
-
- return nil
-}
-
-// locate finds the root of the notebook containing the given path.
-func locateRoot(path string) (string, error) {
- if !filepath.IsAbs(path) {
- panic("absolute path expected")
- }
-
- var locate func(string) (string, error)
- locate = func(currentPath string) (string, error) {
- if currentPath == "/" || currentPath == "." {
- return "", ErrNotebookNotFound(path)
- }
- exists, err := paths.DirExists(filepath.Join(currentPath, ".zk"))
- switch {
- case err != nil:
- return "", err
- case exists:
- return currentPath, nil
- default:
- return locate(filepath.Dir(currentPath))
- }
- }
-
- return locate(path)
-}
-
-// DBPath returns the path to the notebook database.
-func (zk *Zk) DBPath() string {
- return filepath.Join(zk.Path, ".zk/notebook.db")
-}
-
-// RelPath returns the path relative to the notebook root to the given path.
-func (zk *Zk) RelPath(originalPath string) (string, error) {
- wrap := errors.Wrapperf("%v: not a valid notebook path", originalPath)
-
- path, err := zk.absPath(originalPath)
- if err != nil {
- return path, wrap(err)
- }
-
- path, err = filepath.Rel(zk.Path, path)
- if err != nil {
- return path, wrap(err)
- }
- if strings.HasPrefix(path, "..") {
- return path, fmt.Errorf("%s: path is outside the notebook", originalPath)
- }
- if path == "." {
- path = ""
- }
- return path, nil
-}
-
-// AbsPath makes the given path absolute, using the current working directory
-// as reference.
-func (zk *Zk) absPath(originalPath string) (string, error) {
- var err error
-
- path := originalPath
- if !filepath.IsAbs(path) {
- path = filepath.Join(zk.workingDir, path)
- path, err = filepath.Abs(path)
- if err != nil {
- return path, err
- }
- }
-
- return path, nil
-}
-
-// RootDir returns the root Dir for this notebook.
-func (zk *Zk) RootDir() Dir {
- return Dir{
- Name: "",
- Path: zk.Path,
- Config: zk.Config.RootGroupConfig(),
- }
-}
-
-// DirAt returns a Dir representation of the notebook directory at the given path.
-func (zk *Zk) DirAt(path string, overrides ...ConfigOverrides) (*Dir, error) {
- path, err := zk.absPath(path)
- if err != nil {
- return nil, errors.Wrapf(err, "%v: not a valid notebook directory", path)
- }
-
- name, err := zk.RelPath(path)
- if err != nil {
- return nil, err
- }
-
- config, err := zk.findConfigForDirNamed(name, overrides)
- if err != nil {
- return nil, err
- }
-
- config = config.Clone()
- for _, v := range overrides {
- config.Override(v)
- }
-
- return &Dir{
- Name: name,
- Path: path,
- Config: config,
- }, nil
-}
-
-func (zk *Zk) findConfigForDirNamed(name string, overrides []ConfigOverrides) (GroupConfig, error) {
- // If there's a Group overrides, attempt to find a matching group.
- overriddenGroup := ""
- for _, o := range overrides {
- if !o.Group.IsNull() {
- overriddenGroup = o.Group.Unwrap()
- if group, ok := zk.Config.Groups[overriddenGroup]; ok {
- return group, nil
- }
- }
- }
-
- if overriddenGroup != "" {
- return GroupConfig{}, fmt.Errorf("%s: group not find in the config file", overriddenGroup)
- }
-
- for groupName, group := range zk.Config.Groups {
- for _, path := range group.Paths {
- matches, err := filepath.Match(path, name)
- if err != nil {
- return GroupConfig{}, errors.Wrapf(err, "failed to match group %s to %s", groupName, name)
- } else if matches {
- return group, nil
- }
- }
- }
- // Fallback on root config.
- return zk.Config.RootGroupConfig(), nil
-}
-
-// RequiredDirAt is the same as DirAt, but checks that the directory exists
-// before returning the Dir.
-func (zk *Zk) RequireDirAt(path string, overrides ...ConfigOverrides) (*Dir, error) {
- dir, err := zk.DirAt(path, overrides...)
- if err != nil {
- return nil, err
- }
- exists, err := paths.Exists(dir.Path)
- if err != nil {
- return nil, err
- }
- if !exists {
- return nil, fmt.Errorf("%v: directory not found", path)
- }
- return dir, nil
-}
diff --git a/internal/core/style.go b/internal/core/style.go
new file mode 100644
index 0000000..0b11786
--- /dev/null
+++ b/internal/core/style.go
@@ -0,0 +1,89 @@
+package core
+
+// Style is a key representing a single styling rule.
+type Style string
+
+// Predefined styling rules.
+var (
+ // Title of a note.
+ StyleTitle = Style("title")
+ // Path to notebook file.
+ StylePath = Style("path")
+ // Searched for term in a note.
+ StyleTerm = Style("term")
+ // Element to emphasize, for example the short version of a prompt response: [y]es.
+ StyleEmphasis = Style("emphasis")
+ // Element to understate, for example the content of the note in fzf.
+ StyleUnderstate = Style("understate")
+
+ StyleBold = Style("bold")
+ StyleItalic = Style("italic")
+ StyleFaint = Style("faint")
+ StyleUnderline = Style("underline")
+ StyleStrikethrough = Style("strikethrough")
+ StyleBlink = Style("blink")
+ StyleReverse = Style("reverse")
+ StyleHidden = Style("hidden")
+
+ StyleBlack = Style("black")
+ StyleRed = Style("red")
+ StyleGreen = Style("green")
+ StyleYellow = Style("yellow")
+ StyleBlue = Style("blue")
+ StyleMagenta = Style("magenta")
+ StyleCyan = Style("cyan")
+ StyleWhite = Style("white")
+
+ StyleBlackBg = Style("black-bg")
+ StyleRedBg = Style("red-bg")
+ StyleGreenBg = Style("green-bg")
+ StyleYellowBg = Style("yellow-bg")
+ StyleBlueBg = Style("blue-bg")
+ StyleMagentaBg = Style("magenta-bg")
+ StyleCyanBg = Style("cyan-bg")
+ StyleWhiteBg = Style("white-bg")
+
+ StyleBrightBlack = Style("bright-black")
+ StyleBrightRed = Style("bright-red")
+ StyleBrightGreen = Style("bright-green")
+ StyleBrightYellow = Style("bright-yellow")
+ StyleBrightBlue = Style("bright-blue")
+ StyleBrightMagenta = Style("bright-magenta")
+ StyleBrightCyan = Style("bright-cyan")
+ StyleBrightWhite = Style("bright-white")
+
+ StyleBrightBlackBg = Style("bright-black-bg")
+ StyleBrightRedBg = Style("bright-red-bg")
+ StyleBrightGreenBg = Style("bright-green-bg")
+ StyleBrightYellowBg = Style("bright-yellow-bg")
+ StyleBrightBlueBg = Style("bright-blue-bg")
+ StyleBrightMagentaBg = Style("bright-magenta-bg")
+ StyleBrightCyanBg = Style("bright-cyan-bg")
+ StyleBrightWhiteBg = Style("bright-white-bg")
+)
+
+// 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 formats the given text according to the provided styling rules.
+ Style(text string, rules ...Style) (string, error)
+ // Style formats the given text according to the provided styling rules,
+ // panicking if the rules are unknown.
+ MustStyle(text string, rules ...Style) string
+}
+
+// NullStyler is a Styler with no styling rules.
+var NullStyler = nullStyler{}
+
+type nullStyler struct{}
+
+// Style implements Styler.
+func (s nullStyler) Style(text string, rule ...Style) (string, error) {
+ return text, nil
+}
+
+// MustStyle implements Styler.
+func (s nullStyler) MustStyle(text string, rule ...Style) string {
+ return text
+}
diff --git a/internal/core/style_test.go b/internal/core/style_test.go
new file mode 100644
index 0000000..d7db8e0
--- /dev/null
+++ b/internal/core/style_test.go
@@ -0,0 +1,18 @@
+package core
+
+import "fmt"
+
+// stylerMock implements core.Styler by doing the transformation:
+// "hello", "red" -> "red(hello)"
+type stylerMock struct{}
+
+func (s *stylerMock) Style(text string, rules ...Style) (string, error) {
+ return s.MustStyle(text, rules...), nil
+}
+
+func (s *stylerMock) MustStyle(text string, rules ...Style) string {
+ for _, rule := range rules {
+ text = fmt.Sprintf("%s(%s)", rule, text)
+ }
+ return text
+}
diff --git a/internal/core/template.go b/internal/core/template.go
new file mode 100644
index 0000000..abc9537
--- /dev/null
+++ b/internal/core/template.go
@@ -0,0 +1,52 @@
+package core
+
+// Template produces a string using a given context.
+type Template interface {
+
+ // Styler used to format the templates content.
+ Styler() Styler
+
+ // Render generates this template using the given variable context.
+ Render(context interface{}) (string, error)
+}
+
+// TemplateFunc is an adapter to use a function as a Template.
+type TemplateFunc func(context interface{}) (string, error)
+
+// Styler implements Template.
+func (f TemplateFunc) Styler() Styler {
+ return NullStyler
+}
+
+// Render implements Template.
+func (f TemplateFunc) Render(context interface{}) (string, error) {
+ return f(context)
+}
+
+// NullTemplate is a Template always returning an empty string.
+var NullTemplate = nullTemplate{}
+
+type nullTemplate struct{}
+
+func (t nullTemplate) Styler() Styler {
+ return NullStyler
+}
+
+func (t nullTemplate) Render(context interface{}) (string, error) {
+ return "", nil
+}
+
+// TemplateLoader parses a string into a new Template instance.
+type TemplateLoader interface {
+ // LoadTemplate creates a Template instance from a string template.
+ LoadTemplate(template string) (Template, error)
+
+ // LoadTemplate creates a Template instance from a template stored in the
+ // file at the given path.
+ // The path may be relative to template directories registered to the loader.
+ LoadTemplateAt(path string) (Template, error)
+}
+
+// TemplateLoaderFactory creates a new instance of an implementation of the
+// TemplateLoader port.
+type TemplateLoaderFactory func(language string) (TemplateLoader, error)
diff --git a/internal/core/template_test.go b/internal/core/template_test.go
new file mode 100644
index 0000000..ce6234e
--- /dev/null
+++ b/internal/core/template_test.go
@@ -0,0 +1,83 @@
+package core
+
+// templateLoaderMock implements an in-memory TemplateLoader for testing.
+type templateLoaderMock struct {
+ templates map[string]*templateSpy
+ fileTemplates map[string]*templateSpy
+ styler Styler
+}
+
+func newTemplateLoaderMock() *templateLoaderMock {
+ return &templateLoaderMock{
+ templates: map[string]*templateSpy{},
+ fileTemplates: map[string]*templateSpy{},
+ styler: &stylerMock{},
+ }
+}
+
+func (m *templateLoaderMock) Spy(template string, result func(context interface{}) string) *templateSpy {
+ spy := newTemplateSpy(result)
+ spy.styler = m.styler
+ m.templates[template] = spy
+ return spy
+}
+
+func (m *templateLoaderMock) SpyString(content string) *templateSpy {
+ spy := newTemplateSpyString(content)
+ spy.styler = m.styler
+ m.templates[content] = spy
+ return spy
+}
+
+func (m *templateLoaderMock) SpyFile(path string, content string) *templateSpy {
+ spy := newTemplateSpyString(content)
+ spy.styler = m.styler
+ m.fileTemplates[path] = spy
+ return spy
+}
+
+func (l *templateLoaderMock) LoadTemplate(template string) (Template, error) {
+ tpl, ok := l.templates[template]
+ if !ok {
+ panic("no template spy for content: " + template)
+ }
+ return tpl, nil
+}
+
+func (l *templateLoaderMock) LoadTemplateAt(path string) (Template, error) {
+ tpl, ok := l.fileTemplates[path]
+ if !ok {
+ panic("no template spy for path: " + path)
+ }
+ return tpl, nil
+}
+
+// templateSpy implements Template and saves the provided render contexts.
+type templateSpy struct {
+ Result func(interface{}) string
+ Contexts []interface{}
+ styler Styler
+}
+
+func newTemplateSpy(result func(interface{}) string) *templateSpy {
+ return &templateSpy{
+ Contexts: make([]interface{}, 0),
+ Result: result,
+ }
+}
+
+func newTemplateSpyString(result string) *templateSpy {
+ return &templateSpy{
+ Contexts: make([]interface{}, 0),
+ Result: func(_ interface{}) string { return result },
+ }
+}
+
+func (m *templateSpy) Styler() Styler {
+ return m.styler
+}
+
+func (m *templateSpy) Render(context interface{}) (string, error) {
+ m.Contexts = append(m.Contexts, context)
+ return m.Result(context), nil
+}
diff --git a/util/date/date.go b/internal/util/date/date.go
similarity index 100%
rename from util/date/date.go
rename to internal/util/date/date.go
diff --git a/util/errors/errors.go b/internal/util/errors/errors.go
similarity index 100%
rename from util/errors/errors.go
rename to internal/util/errors/errors.go
diff --git a/util/exec/exec.go b/internal/util/exec/exec.go
similarity index 100%
rename from util/exec/exec.go
rename to internal/util/exec/exec.go
diff --git a/util/exec/exec_unix.go b/internal/util/exec/exec_unix.go
similarity index 100%
rename from util/exec/exec_unix.go
rename to internal/util/exec/exec_unix.go
diff --git a/util/exec/exec_windows.go b/internal/util/exec/exec_windows.go
similarity index 100%
rename from util/exec/exec_windows.go
rename to internal/util/exec/exec_windows.go
diff --git a/util/fixtures/fixtures.go b/internal/util/fixtures/fixtures.go
similarity index 79%
rename from util/fixtures/fixtures.go
rename to internal/util/fixtures/fixtures.go
index 0aae55b..61d6eba 100644
--- a/util/fixtures/fixtures.go
+++ b/internal/util/fixtures/fixtures.go
@@ -11,5 +11,5 @@ func Path(name string) string {
if !ok {
panic("failed to get the caller's path")
}
- return filepath.Join(filepath.Dir(callerPath), "fixtures", name)
+ return filepath.Join(filepath.Dir(callerPath), "testdata", name)
}
diff --git a/util/fts5/fts5.go b/internal/util/fts5/fts5.go
similarity index 100%
rename from util/fts5/fts5.go
rename to internal/util/fts5/fts5.go
diff --git a/util/fts5/fts5_test.go b/internal/util/fts5/fts5_test.go
similarity index 97%
rename from util/fts5/fts5_test.go
rename to internal/util/fts5/fts5_test.go
index ca3ba54..ce13590 100644
--- a/util/fts5/fts5_test.go
+++ b/internal/util/fts5/fts5_test.go
@@ -3,7 +3,7 @@ package fts5
import (
"testing"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestConvertQuery(t *testing.T) {
diff --git a/util/icu/icu.go b/internal/util/icu/icu.go
similarity index 100%
rename from util/icu/icu.go
rename to internal/util/icu/icu.go
diff --git a/util/icu/icu_test.go b/internal/util/icu/icu_test.go
similarity index 92%
rename from util/icu/icu_test.go
rename to internal/util/icu/icu_test.go
index 5a70a06..685dcab 100644
--- a/util/icu/icu_test.go
+++ b/internal/util/icu/icu_test.go
@@ -3,7 +3,7 @@ package icu
import (
"testing"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestEscapePAttern(t *testing.T) {
diff --git a/util/logger.go b/internal/util/logger.go
similarity index 62%
rename from util/logger.go
rename to internal/util/logger.go
index 983310d..8f90812 100644
--- a/util/logger.go
+++ b/internal/util/logger.go
@@ -38,3 +38,25 @@ func (l StdLogger) Err(err error) {
l.Printf("warning: %v", err)
}
}
+
+// ProxyLogger is a logger delegating to an underlying logger.
+// Can be used to change the active logger during runtime.
+type ProxyLogger struct {
+ Logger Logger
+}
+
+func NewProxyLogger(logger Logger) *ProxyLogger {
+ return &ProxyLogger{logger}
+}
+
+func (l *ProxyLogger) Printf(format string, v ...interface{}) {
+ l.Logger.Printf(format, v...)
+}
+
+func (l *ProxyLogger) Println(v ...interface{}) {
+ l.Logger.Println(v...)
+}
+
+func (l *ProxyLogger) Err(err error) {
+ l.Logger.Err(err)
+}
diff --git a/util/opt/opt.go b/internal/util/opt/opt.go
similarity index 100%
rename from util/opt/opt.go
rename to internal/util/opt/opt.go
diff --git a/util/os/os.go b/internal/util/os/os.go
similarity index 94%
rename from util/os/os.go
rename to internal/util/os/os.go
index fb9a0c5..75f5683 100644
--- a/util/os/os.go
+++ b/internal/util/os/os.go
@@ -6,7 +6,7 @@ import (
"os"
"strings"
- "github.com/mickael-menu/zk/util/opt"
+ "github.com/mickael-menu/zk/internal/util/opt"
)
// ReadStdinPipe returns the content of any piped input.
diff --git a/util/pager/pager.go b/internal/util/pager/pager.go
similarity index 91%
rename from util/pager/pager.go
rename to internal/util/pager/pager.go
index 5c9dc13..c52ebf5 100644
--- a/util/pager/pager.go
+++ b/internal/util/pager/pager.go
@@ -9,11 +9,11 @@ import (
"sync"
"github.com/kballard/go-shellquote"
- "github.com/mickael-menu/zk/util"
- "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"
+ "github.com/mickael-menu/zk/internal/util"
+ "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"
)
// Pager writes text to a terminal using the user's pager.
diff --git a/util/paths/diff.go b/internal/util/paths/diff.go
similarity index 100%
rename from util/paths/diff.go
rename to internal/util/paths/diff.go
diff --git a/util/paths/diff_test.go b/internal/util/paths/diff_test.go
similarity index 98%
rename from util/paths/diff_test.go
rename to internal/util/paths/diff_test.go
index d3f9b8d..0197eff 100644
--- a/util/paths/diff_test.go
+++ b/internal/util/paths/diff_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"time"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
var date1 = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC)
diff --git a/util/paths/paths.go b/internal/util/paths/paths.go
similarity index 100%
rename from util/paths/paths.go
rename to internal/util/paths/paths.go
diff --git a/util/paths/fixtures/walk/Dir3/a.md b/internal/util/paths/testdata/walk/Dir3/a.md
similarity index 100%
rename from util/paths/fixtures/walk/Dir3/a.md
rename to internal/util/paths/testdata/walk/Dir3/a.md
diff --git a/util/paths/fixtures/walk/a.md b/internal/util/paths/testdata/walk/a.md
similarity index 100%
rename from util/paths/fixtures/walk/a.md
rename to internal/util/paths/testdata/walk/a.md
diff --git a/util/paths/fixtures/walk/b.md b/internal/util/paths/testdata/walk/b.md
similarity index 100%
rename from util/paths/fixtures/walk/b.md
rename to internal/util/paths/testdata/walk/b.md
diff --git a/util/paths/fixtures/walk/dir1 a space/a.md b/internal/util/paths/testdata/walk/dir1 a space/a.md
similarity index 100%
rename from util/paths/fixtures/walk/dir1 a space/a.md
rename to internal/util/paths/testdata/walk/dir1 a space/a.md
diff --git a/util/paths/fixtures/walk/dir1/.ignored.md b/internal/util/paths/testdata/walk/dir1/.ignored.md
similarity index 100%
rename from util/paths/fixtures/walk/dir1/.ignored.md
rename to internal/util/paths/testdata/walk/dir1/.ignored.md
diff --git a/util/paths/fixtures/walk/dir1/.ignored/a.md b/internal/util/paths/testdata/walk/dir1/.ignored/a.md
similarity index 100%
rename from util/paths/fixtures/walk/dir1/.ignored/a.md
rename to internal/util/paths/testdata/walk/dir1/.ignored/a.md
diff --git a/util/paths/fixtures/walk/dir1/a.md b/internal/util/paths/testdata/walk/dir1/a.md
similarity index 100%
rename from util/paths/fixtures/walk/dir1/a.md
rename to internal/util/paths/testdata/walk/dir1/a.md
diff --git a/util/paths/fixtures/walk/dir1/b.md b/internal/util/paths/testdata/walk/dir1/b.md
similarity index 100%
rename from util/paths/fixtures/walk/dir1/b.md
rename to internal/util/paths/testdata/walk/dir1/b.md
diff --git a/util/paths/fixtures/walk/dir1/dir1/a.md b/internal/util/paths/testdata/walk/dir1/dir1/a.md
similarity index 100%
rename from util/paths/fixtures/walk/dir1/dir1/a.md
rename to internal/util/paths/testdata/walk/dir1/dir1/a.md
diff --git a/util/paths/fixtures/walk/dir1/ignored.txt b/internal/util/paths/testdata/walk/dir1/ignored.txt
similarity index 100%
rename from util/paths/fixtures/walk/dir1/ignored.txt
rename to internal/util/paths/testdata/walk/dir1/ignored.txt
diff --git a/util/paths/fixtures/walk/dir2/a.md b/internal/util/paths/testdata/walk/dir2/a.md
similarity index 100%
rename from util/paths/fixtures/walk/dir2/a.md
rename to internal/util/paths/testdata/walk/dir2/a.md
diff --git a/util/paths/walk.go b/internal/util/paths/walk.go
similarity index 95%
rename from util/paths/walk.go
rename to internal/util/paths/walk.go
index 51462c8..66e329a 100644
--- a/util/paths/walk.go
+++ b/internal/util/paths/walk.go
@@ -5,7 +5,7 @@ import (
"path/filepath"
"strings"
- "github.com/mickael-menu/zk/util"
+ "github.com/mickael-menu/zk/internal/util"
)
// Walk emits the metadata of each file stored in the directory with the given extension.
diff --git a/util/paths/walk_test.go b/internal/util/paths/walk_test.go
similarity index 73%
rename from util/paths/walk_test.go
rename to internal/util/paths/walk_test.go
index c692021..cf0ef12 100644
--- a/util/paths/walk_test.go
+++ b/internal/util/paths/walk_test.go
@@ -3,9 +3,9 @@ package paths
import (
"testing"
- "github.com/mickael-menu/zk/util"
- "github.com/mickael-menu/zk/util/fixtures"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util"
+ "github.com/mickael-menu/zk/internal/util/fixtures"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestWalk(t *testing.T) {
diff --git a/util/rand/rand.go b/internal/util/rand/rand.go
similarity index 83%
rename from util/rand/rand.go
rename to internal/util/rand/rand.go
index b209a40..5f3c2f6 100644
--- a/util/rand/rand.go
+++ b/internal/util/rand/rand.go
@@ -5,12 +5,12 @@ import (
"time"
"unicode"
- "github.com/mickael-menu/zk/core/zk"
+ "github.com/mickael-menu/zk/internal/core"
)
// NewIDGenerator returns a function generating string IDs using the given options.
// Inspired by https://www.calhoun.io/creating-random-strings-in-go/
-func NewIDGenerator(options zk.IDOptions) func() string {
+func NewIDGenerator(options core.IDOptions) func() string {
if options.Length < 1 {
panic("IDOptions.Length must be at least 1")
}
@@ -18,11 +18,11 @@ func NewIDGenerator(options zk.IDOptions) func() string {
var charset []rune
for _, char := range options.Charset {
switch options.Case {
- case zk.CaseLower:
+ case core.CaseLower:
charset = append(charset, unicode.ToLower(char))
- case zk.CaseUpper:
+ case core.CaseUpper:
charset = append(charset, unicode.ToUpper(char))
- case zk.CaseMixed:
+ case core.CaseMixed:
charset = append(charset, unicode.ToLower(char))
charset = append(charset, unicode.ToUpper(char))
default:
diff --git a/util/strings/strings.go b/internal/util/strings/strings.go
similarity index 100%
rename from util/strings/strings.go
rename to internal/util/strings/strings.go
diff --git a/util/strings/strings_test.go b/internal/util/strings/strings_test.go
similarity index 98%
rename from util/strings/strings_test.go
rename to internal/util/strings/strings_test.go
index 3cb6c7a..ed0154e 100644
--- a/util/strings/strings_test.go
+++ b/internal/util/strings/strings_test.go
@@ -3,7 +3,7 @@ package strings
import (
"testing"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
func TestPrepend(t *testing.T) {
diff --git a/util/test/assert/assert.go b/internal/util/test/assert/assert.go
similarity index 100%
rename from util/test/assert/assert.go
rename to internal/util/test/assert/assert.go
diff --git a/util/yaml/yaml.go b/internal/util/yaml/yaml.go
similarity index 100%
rename from util/yaml/yaml.go
rename to internal/util/yaml/yaml.go
diff --git a/util/yaml/yaml_test.go b/internal/util/yaml/yaml_test.go
similarity index 95%
rename from util/yaml/yaml_test.go
rename to internal/util/yaml/yaml_test.go
index 9bef505..2b875c0 100644
--- a/util/yaml/yaml_test.go
+++ b/internal/util/yaml/yaml_test.go
@@ -3,7 +3,7 @@ package yaml
import (
"testing"
- "github.com/mickael-menu/zk/util/test/assert"
+ "github.com/mickael-menu/zk/internal/util/test/assert"
)
// Credit: https://github.com/icza/dyno
diff --git a/main.go b/main.go
index 1b2ccef..cc292b0 100644
--- a/main.go
+++ b/main.go
@@ -8,16 +8,16 @@ import (
"strings"
"github.com/alecthomas/kong"
- "github.com/mickael-menu/zk/adapter"
- "github.com/mickael-menu/zk/cmd"
- "github.com/mickael-menu/zk/core/style"
- executil "github.com/mickael-menu/zk/util/exec"
+ "github.com/mickael-menu/zk/internal/cli"
+ "github.com/mickael-menu/zk/internal/cli/cmd"
+ "github.com/mickael-menu/zk/internal/core"
+ executil "github.com/mickael-menu/zk/internal/util/exec"
)
var Version = "dev"
var Build = "dev"
-var cli struct {
+var root struct {
Init cmd.Init `cmd group:"zk" help:"Create a new notebook in the given directory."`
Index cmd.Index `cmd group:"zk" help:"Index the notes to be searchable."`
@@ -36,7 +36,7 @@ var cli struct {
// NoInput is a flag preventing any user prompt when enabled.
type NoInput bool
-func (f NoInput) BeforeApply(container *adapter.Container) error {
+func (f NoInput) BeforeApply(container *cli.Container) error {
container.Terminal.NoInput = true
return nil
}
@@ -44,8 +44,8 @@ func (f NoInput) BeforeApply(container *adapter.Container) error {
// ShowHelp is the default command run. It's equivalent to `zk --help`.
type ShowHelp struct{}
-func (cmd *ShowHelp) Run(container *adapter.Container) error {
- parser, err := kong.New(&cli, options(container)...)
+func (cmd *ShowHelp) Run(container *cli.Container) error {
+ parser, err := kong.New(&root, options(container)...)
if err != nil {
return err
}
@@ -58,25 +58,35 @@ func (cmd *ShowHelp) Run(container *adapter.Container) error {
func main() {
// Create the dependency graph.
- container, err := adapter.NewContainer(Version)
+ container, err := cli.NewContainer(Version)
fatalIfError(err)
// Open the notebook if there's any.
searchPaths, err := notebookSearchPaths()
fatalIfError(err)
- container.OpenNotebook(searchPaths)
+ container.SetCurrentNotebook(searchPaths)
// Run the alias or command.
if isAlias, err := runAlias(container, os.Args[1:]); isAlias {
fatalIfError(err)
} else {
- ctx := kong.Parse(&cli, options(container)...)
+ ctx := kong.Parse(&root, options(container)...)
+
+ // Index the current notebook except if the user is running the `index`
+ // command, otherwise it would hide the stats.
+ if ctx.Command() != "index" {
+ if notebook, err := container.CurrentNotebook(); err == nil {
+ _, err = notebook.Index(false)
+ ctx.FatalIfErrorf(err)
+ }
+ }
+
err := ctx.Run(container)
ctx.FatalIfErrorf(err)
}
}
-func options(container *adapter.Container) []kong.Option {
+func options(container *cli.Container) []kong.Option {
term := container.Terminal
return []kong.Option{
kong.Bind(container),
@@ -93,8 +103,8 @@ func options(container *adapter.Container) []kong.Option {
"filter": "Filtering",
"sort": "Sorting",
"format": "Formatting",
- "notes": term.MustStyle("NOTES", style.RuleYellow, style.RuleBold) + "\n" + term.MustStyle("Edit or browse your notes", style.RuleBold),
- "zk": term.MustStyle("NOTEBOOK", style.RuleYellow, style.RuleBold) + "\n" + term.MustStyle("A notebook is a directory containing a collection of notes", style.RuleBold),
+ "notes": term.MustStyle("NOTES", core.StyleYellow, core.StyleBold) + "\n" + term.MustStyle("Edit or browse your notes", core.StyleBold),
+ "zk": term.MustStyle("NOTEBOOK", core.StyleYellow, core.StyleBold) + "\n" + term.MustStyle("A notebook is a directory containing a collection of notes", core.StyleBold),
}),
}
}
@@ -107,7 +117,7 @@ func fatalIfError(err error) {
}
// runAlias will execute a user alias if the command is one of them.
-func runAlias(container *adapter.Container, args []string) (bool, error) {
+func runAlias(container *cli.Container, args []string) (bool, error) {
if len(args) < 1 {
return false, nil
}
@@ -121,9 +131,10 @@ func runAlias(container *adapter.Container, args []string) (bool, error) {
// Prevent infinite loop if an alias calls itself.
os.Setenv("ZK_RUNNING_ALIAS", alias)
- // Move to the provided working directory if it is not the current one,
- // before running the alias.
- cmdStr = `cd "` + container.WorkingDir + `" && ` + cmdStr
+ // Move to the current notebook's root directory before running the alias.
+ if notebook, err := container.CurrentNotebook(); err == nil {
+ cmdStr = `cd "` + notebook.Path + `" && ` + cmdStr
+ }
cmd := executil.CommandFromString(cmdStr, args[1:]...)
cmd.Stdin = os.Stdin