From e653c71356b8d7c8f533b26aabc90eb2f4a14846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 18 Mar 2021 19:47:49 +0100 Subject: [PATCH] Specify the notebook directory explicitly (#14) --- CHANGELOG.md | 4 +++ cmd/container.go | 69 ++++++++++++++++++++++++++--------------- cmd/edit.go | 12 ++------ cmd/index.go | 7 +---- cmd/list.go | 17 ++++------ cmd/new.go | 4 +-- core/zk/zk.go | 50 +++++++++++++++++++++++------- docs/config-alias.md | 8 ++--- docs/daily-journal.md | 6 ++-- docs/notebook.md | 2 +- main.go | 72 +++++++++++++++++++++++++++++++++++++++++-- 11 files changed, 176 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8455a3a..2f1e2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to this project will be documented in this file. * This is the same format as a notebook [configuration file](docs/config.md). * Shared templates can be stored in `~/.config/zk/templates/`. * `XDG_CONFIG_HOME` is taken into account. +* Use `--notebook-dir` or set `ZK_NOTEBOOK_DIR` to run `zk` as if it was started from this path instead of the current working directory. + * This allows running `zk` without being in a notebook. + * By setting `ZK_NOTEBOOK_DIR` in your shell configuration file (e.g. `~/.profile`), you are declaring a default global notebook which will be used when `zk` is not in a notebook. + * When the notebook directory is set explicitly, any path given as argument will be relative to it instead of the actual working directory. ## 0.2.1 diff --git a/cmd/container.go b/cmd/container.go index c6f6fc1..487b695 100644 --- a/cmd/container.go +++ b/cmd/container.go @@ -26,6 +26,7 @@ type Container struct { Date date.Provider Logger util.Logger Terminal *term.Terminal + WorkingDir string templateLoader *handlebars.Loader zk *zk.Zk zkErr error @@ -48,24 +49,15 @@ func NewContainer() (*Container, error) { } } - // Open current notebook - zk, zkErr := zk.Open(".", config) - if zkErr == nil { - config = zk.Config - os.Setenv("ZK_PATH", zk.Path) - } - date := date.NewFrozenNow() return &Container{ Config: config, - Logger: util.NewStdLogger("zk: ", 0), // zk is short-lived, so we freeze the current date to use the same - // date for any rendering during the execution. + // date for any template rendering during the execution. Date: &date, + Logger: util.NewStdLogger("zk: ", 0), Terminal: term.New(), - zk: zk, - zkErr: zkErr, }, nil } @@ -94,6 +86,24 @@ func locateGlobalConfig() (string, error) { } } +// 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 } @@ -106,11 +116,11 @@ func (c *Container) TemplateLoader(lang string) *handlebars.Loader { return c.templateLoader } -func (c *Container) Parser(zk *zk.Zk) *markdown.Parser { +func (c *Container) Parser() *markdown.Parser { return markdown.NewParser(markdown.ParserOpts{ - HashtagEnabled: zk.Config.Format.Markdown.Hashtags, - MultiWordTagEnabled: zk.Config.Format.Markdown.MultiwordTags, - ColontagEnabled: zk.Config.Format.Markdown.ColonTags, + HashtagEnabled: c.Config.Format.Markdown.Hashtags, + MultiWordTagEnabled: c.Config.Format.Markdown.MultiwordTags, + ColontagEnabled: c.Config.Format.Markdown.ColonTags, }) } @@ -127,10 +137,14 @@ func (c *Container) NoteIndexer(tx sqlite.Transaction) *sqlite.NoteIndexer { // Database returns the DB instance for the given notebook, after executing any // pending migration and indexing the notes if needed. -func (c *Container) Database(zk *zk.Zk, forceIndexing bool) (*sqlite.DB, note.IndexingStats, error) { +func (c *Container) Database(forceIndexing bool) (*sqlite.DB, note.IndexingStats, error) { var stats note.IndexingStats - db, err := sqlite.Open(zk.DBPath()) + if c.zkErr != nil { + return nil, stats, c.zkErr + } + + db, err := sqlite.Open(c.zk.DBPath()) if err != nil { return nil, stats, err } @@ -139,7 +153,7 @@ func (c *Container) Database(zk *zk.Zk, forceIndexing bool) (*sqlite.DB, note.In return nil, stats, errors.Wrap(err, "failed to migrate the database") } - stats, err = c.index(zk, db, forceIndexing || needsReindexing) + stats, err = c.index(db, forceIndexing || needsReindexing) if err != nil { return nil, stats, err } @@ -147,7 +161,7 @@ func (c *Container) Database(zk *zk.Zk, forceIndexing bool) (*sqlite.DB, note.In return db, stats, err } -func (c *Container) index(zk *zk.Zk, db *sqlite.DB, force bool) (note.IndexingStats, error) { +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), @@ -156,11 +170,16 @@ func (c *Container) index(zk *zk.Zk, db *sqlite.DB, force bool) (note.IndexingSt 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( - zk, + c.zk, force, - c.Parser(zk), + c.Parser(), c.NoteIndexer(tx), c.Logger, func(change paths.DiffChange) { @@ -179,8 +198,8 @@ func (c *Container) index(zk *zk.Zk, db *sqlite.DB, force bool) (note.IndexingSt // 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, config zk.Config, run func(out io.Writer) error) error { - pager, err := c.pager(noPager || config.Tool.Pager.IsEmpty(), config) +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 } @@ -189,10 +208,10 @@ func (c *Container) Paginate(noPager bool, config zk.Config, run func(out io.Wri return err } -func (c *Container) pager(noPager bool, config zk.Config) (*pager.Pager, error) { +func (c *Container) pager(noPager bool) (*pager.Pager, error) { if noPager || !c.Terminal.IsInteractive() { return pager.PassthroughPager, nil } else { - return pager.New(config.Tool.Pager, c.Logger) + return pager.New(c.Config.Tool.Pager, c.Logger) } } diff --git a/cmd/edit.go b/cmd/edit.go index 1a2ed2a..518cbd8 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "path/filepath" "github.com/mickael-menu/zk/adapter/fzf" @@ -26,17 +25,12 @@ func (cmd *Edit) Run(container *Container) error { return err } - wd, err := os.Getwd() - if err != nil { - return err - } - opts, err := NewFinderOpts(zk, cmd.Filtering, cmd.Sorting) if err != nil { return errors.Wrapf(err, "incorrect criteria") } - db, _, err := container.Database(zk, false) + db, _, err := container.Database(false) if err != nil { return err } @@ -45,10 +39,10 @@ func (cmd *Edit) Run(container *Container) error { err = db.WithTransaction(func(tx sqlite.Transaction) error { finder := container.NoteFinder(tx, fzf.NoteFinderOpts{ AlwaysFilter: true, - PreviewCmd: zk.Config.Tool.FzfPreview, + PreviewCmd: container.Config.Tool.FzfPreview, NewNoteDir: cmd.newNoteDir(zk), BasePath: zk.Path, - CurrentPath: wd, + CurrentPath: container.WorkingDir, }) notes, err = finder.Find(*opts) return err diff --git a/cmd/index.go b/cmd/index.go index e43b13d..c3a9a76 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -15,12 +15,7 @@ func (cmd *Index) Help() string { } func (cmd *Index) Run(container *Container) error { - zk, err := container.Zk() - if err != nil { - return err - } - - _, stats, err := container.Database(zk, cmd.Force) + _, stats, err := container.Database(cmd.Force) if err != nil { return err } diff --git a/cmd/list.go b/cmd/list.go index 8931d57..2f7e01a 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -39,20 +39,15 @@ func (cmd *List) Run(container *Container) error { return err } - db, _, err := container.Database(zk, false) + db, _, err := container.Database(false) if err != nil { return err } - wd, err := os.Getwd() - if err != nil { - return err - } - - templates := container.TemplateLoader(zk.Config.Note.Lang) + templates := container.TemplateLoader(container.Config.Note.Lang) styler := container.Terminal format := opt.NewNotEmptyString(cmd.Format) - formatter, err := note.NewFormatter(zk.Path, wd, format, templates, styler) + formatter, err := note.NewFormatter(zk.Path, container.WorkingDir, format, templates, styler) if err != nil { return err } @@ -61,9 +56,9 @@ func (cmd *List) Run(container *Container) error { err = db.WithTransaction(func(tx sqlite.Transaction) error { finder := container.NoteFinder(tx, fzf.NoteFinderOpts{ AlwaysFilter: false, - PreviewCmd: zk.Config.Tool.FzfPreview, + PreviewCmd: container.Config.Tool.FzfPreview, BasePath: zk.Path, - CurrentPath: wd, + CurrentPath: container.WorkingDir, }) notes, err = finder.Find(*opts) return err @@ -77,7 +72,7 @@ func (cmd *List) Run(container *Container) error { count := len(notes) if count > 0 { - err = container.Paginate(cmd.NoPager, zk.Config, func(out io.Writer) error { + err = container.Paginate(cmd.NoPager, func(out io.Writer) error { for i, note := range notes { if i > 0 { fmt.Fprint(out, cmd.Delimiter) diff --git a/cmd/new.go b/cmd/new.go index 2fa2504..591cd0c 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -12,7 +12,7 @@ import ( // New adds a new note to the notebook. type New struct { - Directory string `arg optional type:"path" default:"." help:"Directory in which to create the note."` + 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."` @@ -46,7 +46,7 @@ func (cmd *New) Run(container *Container) error { } opts := note.CreateOpts{ - Config: zk.Config, + Config: container.Config, Dir: *dir, Title: opt.NewNotEmptyString(cmd.Title), Content: content, diff --git a/core/zk/zk.go b/core/zk/zk.go index 493614d..da17bcb 100644 --- a/core/zk/zk.go +++ b/core/zk/zk.go @@ -9,6 +9,13 @@ import ( "github.com/mickael-menu/zk/util/paths" ) +// ErrNotebookNotFound is an error returned when a notebook cannot be found at the given path or its parents. +type ErrNotebookNotFound string + +func (e ErrNotebookNotFound) Error() string { + return fmt.Sprintf("no notebook found in %s or a parent directory", string(e)) +} + const defaultConfig = `# zk configuration file # # Uncomment the properties you want to customize. @@ -150,7 +157,7 @@ hashtags = true #hist = "zk list --format path --delimiter0 --quiet $@ | xargs -t -0 git log --patch --" # Edit this configuration file. -#conf = '$EDITOR "$ZK_PATH/.zk/config.toml"' +#conf = '$EDITOR "$ZK_NOTEBOOK_DIR/.zk/config.toml"' ` const defaultTemplate = `# {{title}} @@ -164,6 +171,8 @@ type Zk struct { Path string // Global user configuration. Config Config + // Working directory from which paths are relative. + workingDir string } // Dir represents a directory inside a notebook. @@ -177,10 +186,10 @@ type Dir struct { } // Open locates a notebook at the given path and parses its configuration. -func Open(path string, parentConfig Config) (*Zk, error) { +func Open(originalPath string, parentConfig Config) (*Zk, error) { wrap := errors.Wrapper("open failed") - path, err := filepath.Abs(path) + path, err := filepath.Abs(originalPath) if err != nil { return nil, wrap(err) } @@ -195,8 +204,9 @@ func Open(path string, parentConfig Config) (*Zk, error) { } return &Zk{ - Path: path, - Config: config, + Path: path, + Config: config, + workingDir: originalPath, }, nil } @@ -237,7 +247,7 @@ func locateRoot(path string) (string, error) { var locate func(string) (string, error) locate = func(currentPath string) (string, error) { if currentPath == "/" || currentPath == "." { - return "", fmt.Errorf("no notebook found in %v or a parent directory", path) + return "", ErrNotebookNotFound(path) } exists, err := paths.DirExists(filepath.Join(currentPath, ".zk")) switch { @@ -259,19 +269,20 @@ func (zk *Zk) DBPath() string { } // RelPath returns the path relative to the notebook root to the given path. -func (zk *Zk) RelPath(absPath string) (string, error) { - wrap := errors.Wrapperf("%v: not a valid notebook path", absPath) +func (zk *Zk) RelPath(originalPath string) (string, error) { + wrap := errors.Wrapperf("%v: not a valid notebook path", originalPath) - path, err := filepath.Abs(absPath) + 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", absPath) + return path, fmt.Errorf("%s: path is outside the notebook", originalPath) } if path == "." { path = "" @@ -279,6 +290,23 @@ func (zk *Zk) RelPath(absPath string) (string, error) { 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{ @@ -290,7 +318,7 @@ func (zk *Zk) RootDir() Dir { // 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 := filepath.Abs(path) + path, err := zk.absPath(path) if err != nil { return nil, errors.Wrapf(err, "%v: not a valid notebook directory", path) } diff --git a/docs/config-alias.md b/docs/config-alias.md index fe495d2..8c4cada 100644 --- a/docs/config-alias.md +++ b/docs/config-alias.md @@ -20,10 +20,10 @@ An alias can call other aliases but cannot call itself. This enables you to over edit = "zk edit --interactive $@" ``` -When running an alias, the `ZK_PATH` environment variable is set to the absolute path of the current notebook. You can use it to run commands working no matter the location of the working directory. +When running an alias, the `ZK_NOTEBOOK_DIR` environment variable is set to the absolute path of the current notebook. You can use it to run commands working no matter the location of the working directory. ```toml -journal = 'zk new "$ZK_PATH/journal"' +journal = 'zk new "$ZK_NOTEBOOK_DIR/journal"' ``` If you need to surround the path with quotes, make sure you use double quotes, otherwise environment variables will not be expanded. @@ -70,10 +70,10 @@ recent = "zk edit --sort created- --created-after 'last two weeks' --interactive ### Edit the configuration file -Here's a concrete example using environment variables, in particular `ZK_PATH`. Note the double quotes around the path. +Here's a concrete example using environment variables, in particular `ZK_NOTEBOOK_DIR`. Note the double quotes around the path. ```toml -conf = '$EDITOR "$ZK_PATH/.zk/config.toml"' +conf = '$EDITOR "$ZK_NOTEBOOK_DIR/.zk/config.toml"' ``` ### List paths in a command-line friendly fashion diff --git a/docs/daily-journal.md b/docs/daily-journal.md index 2406fb9..517e5ef 100644 --- a/docs/daily-journal.md +++ b/docs/daily-journal.md @@ -34,12 +34,12 @@ That is a bit of a mouthful for a command called every day. Would it not be bett ```toml [alias] -daily = 'zk new --no-input "$ZK_PATH/journal/daily"' +daily = 'zk new --no-input "$ZK_NOTEBOOK_DIR/journal/daily"' ``` Let's unpack this alias: * `zk new` will refuse to overwrite notes. If you already created today's note, it will instead ask you if you wish to edit it. Using `--no-input` skips the prompt and edit the existing note right away. -* `$ZK_PATH` is set to the absolute path of the current [notebook](notebook.md) when running an alias. Using it allows you to run `zk daily` no matter where you are in the notebook folder hierarchy. -* We need to use double quotes around `$ZK_PATH`, otherwise it will not be expanded. +* `$ZK_NOTEBOOK_DIR` is set to the absolute path of the current [notebook](notebook.md) when running an alias. Using it allows you to run `zk daily` no matter where you are in the notebook folder hierarchy. +* We need to use double quotes around `$ZK_NOTEBOOK_DIR`, otherwise it will not be expanded. diff --git a/docs/notebook.md b/docs/notebook.md index 0644446..4b02c73 100644 --- a/docs/notebook.md +++ b/docs/notebook.md @@ -4,7 +4,7 @@ A *notebook* is a directory containing a collection of notes managed by `zk`. No To create a new notebook, simply run `zk init []`. -Most `zk` commands are operating "Git-style" on the notebook containing the current working directory (or one of its parents). +Most `zk` commands are operating "Git-style" on the notebook containing the current working directory (or one of its parents). However, you can explicitly set which notebook to use with `--notebook-dir` or the `ZK_NOTEBOOK_DIR` environment variable. Setting `ZK_NOTEBOOK_DIR` in your shell configuration (e.g. `~/.profile`) can be used to define a default notebook which `zk` commands will use when the working directory is not in another notebook. ## Anatomy of a notebook diff --git a/main.go b/main.go index ef9deba..6ff9fa6 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,10 @@ package main import ( + "errors" "fmt" "os" "os/exec" - "strings" "github.com/alecthomas/kong" "github.com/mickael-menu/zk/cmd" @@ -23,7 +23,8 @@ var cli struct { List cmd.List `cmd group:"notes" help:"List notes matching the given criteria."` Edit cmd.Edit `cmd group:"notes" help:"Edit notes matching the given criteria."` - NoInput NoInput `help:"Never prompt or ask for confirmation."` + NoInput NoInput `help:"Never prompt or ask for confirmation."` + NotebookDir string `placeholder:"PATH" help:"Run as if zk was started in instead of the current working directory."` ShowHelp ShowHelp `cmd default:"1" hidden:true` Version kong.VersionFlag `help:"Print zk version." hidden:true` @@ -57,9 +58,14 @@ func main() { container, err := cmd.NewContainer() fatalIfError(err) + // Open the notebook if there's any. + searchPaths, err := notebookSearchPaths() + fatalIfError(err) + container.OpenNotebook(searchPaths) + + // Run the alias or command. if isAlias, err := runAlias(container, os.Args[1:]); isAlias { fatalIfError(err) - } else { ctx := kong.Parse(&cli, options(container)...) err := ctx.Run(container) @@ -112,6 +118,10 @@ func runAlias(container *cmd.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 + cmd := executil.CommandFromString(cmdStr, args[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -130,3 +140,59 @@ func runAlias(container *cmd.Container, args []string) (bool, error) { return false, nil } + +// notebookSearchPaths returns the places where zk will look for a notebook. +// The first successful candidate will be used as the working directory from +// which path arguments are relative from. +// +// By order of precedence: +// 1. --notebook-dir flag +// 2. current working directory +// 3. ZK_NOTEBOOK_DIR environment variable +func notebookSearchPaths() ([]string, error) { + // 1. --notebook-dir flag + notebookDir, err := parseNotebookDirFlag() + if err != nil { + return []string{}, err + } + if notebookDir != "" { + // If --notebook-dir is used, we want to only check there to report errors. + return []string{notebookDir}, nil + } + + candidates := []string{} + + // 2. current working directory + wd, err := os.Getwd() + if err != nil { + return nil, err + } + candidates = append(candidates, wd) + + // 3. ZK_NOTEBOOK_DIR environment variable + if notebookDir, ok := os.LookupEnv("ZK_NOTEBOOK_DIR"); ok { + candidates = append(candidates, notebookDir) + } + + return candidates, nil +} + +// parseNotebookDir returns the path to the notebook specified with the +// --notebook-dir flag. +// +// We need to parse the --notebook-dir flag before Kong, because we might need +// it to resolve zk command aliases before parsing the CLI. +func parseNotebookDirFlag() (string, error) { + foundFlag := false + for _, arg := range os.Args { + if arg == "--notebook-dir" { + foundFlag = true + } else if foundFlag { + return arg, nil + } + } + if foundFlag { + return "", errors.New("--notebook-dir requires an argument") + } + return "", nil +}