Add a global configuration file (#12)

pull/14/head
Mickaël Menu 3 years ago committed by GitHub
parent 925e5337bc
commit b82f1f547c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -2,7 +2,16 @@
All notable changes to this project will be documented in this file.
<!-- ## Unreleased -->
## Unreleased
### Added
* Global `zk` configuration at `~/.config/zk/config.toml`.
* Useful to share aliases or default settings across several [notebooks](docs/notebook.md).
* 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.
## 0.2.1

@ -3,7 +3,7 @@ package cmd
import (
"io"
"os"
"sync"
"path/filepath"
"time"
"github.com/mickael-menu/zk/adapter/fzf"
@ -22,35 +22,79 @@ import (
)
type Container struct {
Config zk.Config
Date date.Provider
Logger util.Logger
Terminal *term.Terminal
templateLoader *handlebars.Loader
zkOnce sync.Once
zk *zk.Zk
zkErr error
zk *zk.Zk
zkErr error
}
func NewContainer() *Container {
func NewContainer() (*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)
}
}
// 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: &date,
Terminal: term.New(),
}
zk: zk,
zkErr: zkErr,
}, nil
}
func (c *Container) OpenZk() (*zk.Zk, error) {
c.zkOnce.Do(func() {
c.zk, c.zkErr = zk.Open(".")
if c.zkErr == nil {
os.Setenv("ZK_PATH", c.zk.Path)
// 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
}
}
func (c *Container) Zk() (*zk.Zk, error) {
return c.zk, c.zkErr
}

@ -21,7 +21,7 @@ type Edit struct {
}
func (cmd *Edit) Run(container *Container) error {
zk, err := container.OpenZk()
zk, err := container.Zk()
if err != nil {
return err
}

@ -15,7 +15,7 @@ func (cmd *Index) Help() string {
}
func (cmd *Index) Run(container *Container) error {
zk, err := container.OpenZk()
zk, err := container.Zk()
if err != nil {
return err
}

@ -29,7 +29,7 @@ func (cmd *List) Run(container *Container) error {
cmd.Delimiter = "\x00"
}
zk, err := container.OpenZk()
zk, err := container.Zk()
if err != nil {
return err
}

@ -30,7 +30,7 @@ func (cmd *New) ConfigOverrides() zk.ConfigOverrides {
}
func (cmd *New) Run(container *Container) error {
zk, err := container.OpenZk()
zk, err := container.Zk()
if err != nil {
return err
}
@ -46,6 +46,7 @@ func (cmd *New) Run(container *Container) error {
}
opts := note.CreateOpts{
Config: zk.Config,
Dir: *dir,
Title: opt.NewNotEmptyString(cmd.Title),
Content: content,

@ -17,6 +17,8 @@ import (
// 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.
@ -52,7 +54,11 @@ func Create(
var bodyTemplate templ.Renderer = templ.NullRenderer
if templatePath := opts.Dir.Config.Note.BodyTemplatePath.Unwrap(); templatePath != "" {
bodyTemplate, err = templateLoader.LoadFile(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)
}

@ -1,10 +1,12 @@
package zk
import (
"io/ioutil"
"path/filepath"
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/opt"
"github.com/mickael-menu/zk/util/paths"
toml "github.com/pelletier/go-toml"
)
@ -16,6 +18,37 @@ type Config struct {
Tool ToolConfig
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.
func NewDefaultConfig() Config {
return Config{
Note: NoteConfig{
FilenameTemplate: "{{id}}",
Extension: "md",
BodyTemplatePath: opt.NullString,
Lang: "en",
DefaultTitle: "Untitled",
IDOptions: IDOptions{
Charset: CharsetAlphanum,
Length: 4,
Case: CaseLower,
},
},
Groups: map[string]GroupConfig{},
Format: FormatConfig{
Markdown: MarkdownConfig{
Hashtags: true,
ColonTags: false,
MultiwordTags: false,
},
},
Aliases: map[string]string{},
Extra: map[string]string{},
TemplatesDirs: []string{},
}
}
// RootGroupConfig returns the default GroupConfig for the root directory and its descendants.
@ -27,6 +60,31 @@ 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)
}
for _, dir := range c.TemplatesDirs {
if candidate := filepath.Join(dir, path); exists(candidate) {
return candidate, true
}
}
return path, false
}
// FormatConfig holds the configuration for document formats, such as Markdown.
type FormatConfig struct {
Markdown MarkdownConfig
@ -35,11 +93,11 @@ type FormatConfig struct {
// MarkdownConfig holds the configuration for Markdown documents.
type MarkdownConfig struct {
// Hashtags indicates whether #hashtags are supported.
Hashtags bool `toml:"hashtags" default:"true"`
Hashtags bool
// ColonTags indicates whether :colon:tags: are supported.
ColonTags bool `toml:"colon-tags" default:"false"`
ColonTags bool
// MultiwordTags indicates whether #multi-word tags# are supported.
MultiwordTags bool `toml:"multiword-tags" default:"false"`
MultiwordTags bool
}
// ToolConfig holds the external tooling configuration.
@ -107,90 +165,110 @@ func (c *GroupConfig) Override(overrides ConfigOverrides) {
}
}
// OpenConfig creates a new Config instance from its TOML representation stored
// in the given file.
func OpenConfig(path string, parentConfig Config) (Config, error) {
content, err := ioutil.ReadFile(path)
if err != nil {
return parentConfig, errors.Wrapf(err, "failed to open config file at %s", path)
}
return ParseConfig(content, path, parentConfig)
}
// ParseConfig creates a new Config instance from its TOML representation.
// templatesDir is the base path for the relative templates.
func ParseConfig(content []byte, templatesDir string) (*Config, error) {
// path is the config absolute path, from which will be derived the base path
// for templates.
//
// The parentConfig will be used to inherit default config settings.
func ParseConfig(content []byte, path string, parentConfig Config) (Config, error) {
config := parentConfig
var tomlConf tomlConfig
err := toml.Unmarshal(content, &tomlConf)
if err != nil {
return nil, errors.Wrap(err, "failed to read config")
}
root := GroupConfig{
Paths: []string{},
Note: NoteConfig{
FilenameTemplate: "{{id}}",
Extension: "md",
BodyTemplatePath: opt.NullString,
Lang: "en",
DefaultTitle: "Untitled",
IDOptions: IDOptions{
Charset: CharsetAlphanum,
Length: 4,
Case: CaseLower,
},
},
Extra: make(map[string]string),
return config, errors.Wrap(err, "failed to read config")
}
// Note
note := tomlConf.Note
if note.Filename != "" {
root.Note.FilenameTemplate = note.Filename
config.Note.FilenameTemplate = note.Filename
}
if note.Extension != "" {
root.Note.Extension = note.Extension
config.Note.Extension = note.Extension
}
if note.Template != "" {
root.Note.BodyTemplatePath = templatePathFromString(note.Template, templatesDir)
config.Note.BodyTemplatePath = opt.NewNotEmptyString(note.Template)
}
if note.IDLength != 0 {
root.Note.IDOptions.Length = note.IDLength
config.Note.IDOptions.Length = note.IDLength
}
if note.IDCharset != "" {
root.Note.IDOptions.Charset = charsetFromString(note.IDCharset)
config.Note.IDOptions.Charset = charsetFromString(note.IDCharset)
}
if note.IDCase != "" {
root.Note.IDOptions.Case = caseFromString(note.IDCase)
config.Note.IDOptions.Case = caseFromString(note.IDCase)
}
if note.Lang != "" {
root.Note.Lang = note.Lang
config.Note.Lang = note.Lang
}
if note.DefaultTitle != "" {
root.Note.DefaultTitle = note.DefaultTitle
config.Note.DefaultTitle = note.DefaultTitle
}
if tomlConf.Extra != nil {
for k, v := range tomlConf.Extra {
root.Extra[k] = v
config.Extra[k] = v
}
}
groups := make(map[string]GroupConfig)
// Groups
for name, dirTOML := range tomlConf.Groups {
groups[name] = root.merge(dirTOML, name, templatesDir)
parent, ok := config.Groups[name]
if !ok {
parent = config.RootGroupConfig()
}
config.Groups[name] = parent.merge(dirTOML, name)
}
aliases := make(map[string]string)
// Format
markdown := tomlConf.Format.Markdown
if markdown.Hashtags != nil {
config.Format.Markdown.Hashtags = *markdown.Hashtags
}
if markdown.ColonTags != nil {
config.Format.Markdown.ColonTags = *markdown.ColonTags
}
if markdown.MultiwordTags != nil {
config.Format.Markdown.MultiwordTags = *markdown.MultiwordTags
}
// Tool
tool := tomlConf.Tool
if tool.Editor != nil {
config.Tool.Editor = opt.NewNotEmptyString(*tool.Editor)
}
if tool.Pager != nil {
config.Tool.Pager = opt.NewStringWithPtr(tool.Pager)
}
if tool.FzfPreview != nil {
config.Tool.FzfPreview = opt.NewStringWithPtr(tool.FzfPreview)
}
// Aliases
if tomlConf.Aliases != nil {
for k, v := range tomlConf.Aliases {
aliases[k] = v
config.Aliases[k] = v
}
}
return &Config{
Note: root.Note,
Groups: groups,
Format: tomlConf.Format,
Tool: ToolConfig{
Editor: opt.NewNotEmptyString(tomlConf.Tool.Editor),
Pager: opt.NewStringWithPtr(tomlConf.Tool.Pager),
FzfPreview: opt.NewStringWithPtr(tomlConf.Tool.FzfPreview),
},
Aliases: aliases,
Extra: root.Extra,
}, nil
config.TemplatesDirs = append([]string{filepath.Join(filepath.Dir(path), "templates")}, config.TemplatesDirs...)
return config, nil
}
func (c GroupConfig) merge(tomlConf tomlGroupConfig, name string, templatesDir string) GroupConfig {
func (c GroupConfig) merge(tomlConf tomlGroupConfig, name string) GroupConfig {
res := c.Clone()
if tomlConf.Paths != nil {
@ -211,7 +289,7 @@ func (c GroupConfig) merge(tomlConf tomlGroupConfig, name string, templatesDir s
res.Note.Extension = note.Extension
}
if note.Template != "" {
res.Note.BodyTemplatePath = templatePathFromString(note.Template, templatesDir)
res.Note.BodyTemplatePath = opt.NewNotEmptyString(note.Template)
}
if note.IDLength != 0 {
res.Note.IDOptions.Length = note.IDLength
@ -241,7 +319,7 @@ func (c GroupConfig) merge(tomlConf tomlGroupConfig, name string, templatesDir s
type tomlConfig struct {
Note tomlNoteConfig
Groups map[string]tomlGroupConfig `toml:"group"`
Format FormatConfig
Format tomlFormatConfig
Tool tomlToolConfig
Extra map[string]string
Aliases map[string]string `toml:"alias"`
@ -264,8 +342,18 @@ type tomlGroupConfig struct {
Extra map[string]string
}
type tomlFormatConfig struct {
Markdown tomlMarkdownConfig
}
type tomlMarkdownConfig struct {
Hashtags *bool `toml:"hashtags"`
ColonTags *bool `toml:"colon-tags"`
MultiwordTags *bool `toml:"multiword-tags"`
}
type tomlToolConfig struct {
Editor string
Editor *string
Pager *string
FzfPreview *string `toml:"fzf-preview"`
}
@ -297,13 +385,3 @@ func caseFromString(c string) Case {
return CaseLower
}
}
func templatePathFromString(template string, templatesDir string) opt.String {
if template == "" {
return opt.NullString
}
if !filepath.IsAbs(template) {
template = filepath.Join(templatesDir, template)
}
return opt.NewString(template)
}

@ -2,7 +2,10 @@ package zk
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/mickael-menu/zk/util/opt"
@ -10,10 +13,10 @@ import (
)
func TestParseDefaultConfig(t *testing.T) {
conf, err := ParseConfig([]byte(""), "")
conf, err := ParseConfig([]byte(""), ".zk/config.toml", NewDefaultConfig())
assert.Nil(t, err)
assert.Equal(t, conf, &Config{
assert.Equal(t, conf, Config{
Note: NoteConfig{
FilenameTemplate: "{{id}}",
Extension: "md",
@ -39,16 +42,15 @@ func TestParseDefaultConfig(t *testing.T) {
Pager: opt.NullString,
FzfPreview: opt.NullString,
},
Aliases: make(map[string]string),
Extra: make(map[string]string),
Aliases: make(map[string]string),
Extra: make(map[string]string),
TemplatesDirs: []string{".zk/templates"},
})
}
func TestParseInvalidConfig(t *testing.T) {
conf, err := ParseConfig([]byte(`;`), "")
_, err := ParseConfig([]byte(`;`), ".zk/config.toml", NewDefaultConfig())
assert.NotNil(t, err)
assert.Nil(t, conf)
}
func TestParseComplete(t *testing.T) {
@ -104,10 +106,10 @@ func TestParseComplete(t *testing.T) {
[group."without path"]
paths = []
`), "")
`), ".zk/config.toml", NewDefaultConfig())
assert.Nil(t, err)
assert.Equal(t, conf, &Config{
assert.Equal(t, conf, Config{
Note: NoteConfig{
FilenameTemplate: "{{id}}.note",
Extension: "txt",
@ -200,6 +202,7 @@ func TestParseComplete(t *testing.T) {
"hello": "world",
"salut": "le monde",
},
TemplatesDirs: []string{".zk/templates"},
})
}
@ -231,10 +234,10 @@ func TestParseMergesGroupConfig(t *testing.T) {
log-ext = "value"
[group.inherited]
`), "")
`), ".zk/config.toml", NewDefaultConfig())
assert.Nil(t, err)
assert.Equal(t, conf, &Config{
assert.Equal(t, conf, Config{
Note: NoteConfig{
FilenameTemplate: "root-filename",
Extension: "txt",
@ -300,6 +303,7 @@ func TestParseMergesGroupConfig(t *testing.T) {
"hello": "world",
"salut": "le monde",
},
TemplatesDirs: []string{".zk/templates"},
})
}
@ -310,7 +314,7 @@ func TestParsePreservePropertiesAllowingEmptyValues(t *testing.T) {
[tool]
pager = ""
fzf-preview = ""
`), "")
`), ".zk/config.toml", NewDefaultConfig())
assert.Nil(t, err)
assert.Equal(t, conf.Tool.Pager.IsNull(), false)
@ -325,7 +329,7 @@ func TestParseIDCharset(t *testing.T) {
[note]
id-charset = "%v"
`, charset)
conf, err := ParseConfig([]byte(toml), "")
conf, err := ParseConfig([]byte(toml), ".zk/config.toml", NewDefaultConfig())
assert.Nil(t, err)
if !cmp.Equal(conf.Note.IDOptions.Charset, expected) {
t.Errorf("Didn't parse ID charset `%v` as expected", charset)
@ -346,7 +350,7 @@ func TestParseIDCase(t *testing.T) {
[note]
id-case = "%v"
`, letterCase)
conf, err := ParseConfig([]byte(toml), "")
conf, err := ParseConfig([]byte(toml), ".zk/config.toml", NewDefaultConfig())
assert.Nil(t, err)
if !cmp.Equal(conf.Note.IDOptions.Case, expected) {
t.Errorf("Didn't parse ID case `%v` as expected", letterCase)
@ -359,21 +363,35 @@ func TestParseIDCase(t *testing.T) {
test("unknown", CaseLower)
}
func TestParseResolvesTemplatePaths(t *testing.T) {
test := func(template string, expected string) {
toml := fmt.Sprintf(`
[note]
template = "%v"
`, template)
conf, err := ParseConfig([]byte(toml), "/test/.zk/templates")
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)
if !cmp.Equal(conf.Note.BodyTemplatePath, opt.NewString(expected)) {
t.Errorf("Didn't resolve template `%v` as expected: %v", template, conf.Note.BodyTemplatePath)
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)
}
}
test("template.tpl", "/test/.zk/templates/template.tpl")
test("/abs/template.tpl", "/abs/template.tpl")
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) {

@ -2,7 +2,6 @@ package zk
import (
"fmt"
"io/ioutil"
"path/filepath"
"strings"
@ -178,7 +177,7 @@ type Dir struct {
}
// Open locates a notebook at the given path and parses its configuration.
func Open(path string) (*Zk, error) {
func Open(path string, parentConfig Config) (*Zk, error) {
wrap := errors.Wrapper("open failed")
path, err := filepath.Abs(path)
@ -190,20 +189,14 @@ func Open(path string) (*Zk, error) {
return nil, wrap(err)
}
configContent, err := ioutil.ReadFile(filepath.Join(path, ".zk/config.toml"))
if err != nil {
return nil, wrap(err)
}
templatesDir := filepath.Join(path, ".zk/templates")
config, err := ParseConfig(configContent, templatesDir)
config, err := OpenConfig(filepath.Join(path, ".zk/config.toml"), parentConfig)
if err != nil {
return nil, wrap(err)
}
return &Zk{
Path: path,
Config: *config,
Config: config,
}, nil
}

@ -12,6 +12,12 @@ Each [notebook](notebook.md) contains a configuration file used to customize you
* [`fzf`](tool-fzf.md)
* `[alias]` holds your [command aliases](config-alias.md)
## Global configuration file
You can also create a global configuration file to share aliases and settings across several notebooks. The global configuration is by default located at `~/.config/zk/config.toml`, but you can customize its location with the [`XDG_CONFIG_HOME`](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) environment variable.
Notebook configuration files will inherit the settings defined in the global configuration file. You can also share templates by storing them under `~/.config/zk/templates/`.
## Complete example
Here's an example of a complete configuration file:

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/alecthomas/kong"
"github.com/mickael-menu/zk/cmd"
@ -53,7 +54,8 @@ func (cmd *ShowHelp) Run(container *cmd.Container) error {
func main() {
// Create the dependency graph.
container := cmd.NewContainer()
container, err := cmd.NewContainer()
fatalIfError(err)
if isAlias, err := runAlias(container, os.Args[1:]); isAlias {
fatalIfError(err)
@ -97,31 +99,33 @@ func fatalIfError(err error) {
// runAlias will execute a user alias if the command is one of them.
func runAlias(container *cmd.Container, args []string) (bool, error) {
if len(args) < 1 {
return false, nil
}
runningAlias := os.Getenv("ZK_RUNNING_ALIAS")
if zk, err := container.OpenZk(); err == nil && len(args) >= 1 {
for alias, cmdStr := range zk.Config.Aliases {
if alias == runningAlias || alias != args[0] {
continue
}
for alias, cmdStr := range container.Config.Aliases {
if alias == runningAlias || alias != args[0] {
continue
}
// Prevent infinite loop if an alias calls itself.
os.Setenv("ZK_RUNNING_ALIAS", alias)
cmd := executil.CommandFromString(cmdStr, args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
if err, ok := err.(*exec.ExitError); ok {
os.Exit(err.ExitCode())
return true, nil
} else {
return true, err
}
// Prevent infinite loop if an alias calls itself.
os.Setenv("ZK_RUNNING_ALIAS", alias)
cmd := executil.CommandFromString(cmdStr, args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
if err, ok := err.(*exec.ExitError); ok {
os.Exit(err.ExitCode())
return true, nil
} else {
return true, err
}
return true, nil
}
return true, nil
}
return false, nil

@ -10,6 +10,18 @@ import (
"github.com/mickael-menu/pretty"
)
func True(t *testing.T, value bool) {
if !value {
t.Errorf("Expected to be true")
}
}
func False(t *testing.T, value bool) {
if value {
t.Errorf("Expected to be false")
}
}
func Nil(t *testing.T, value interface{}) {
if !isNil(value) {
t.Errorf("Expected `%v` (type %v) to be nil", value, reflect.TypeOf(value))

Loading…
Cancel
Save