// Package config loads glint's TOML configuration, falling back to defaults. package config import ( "errors" "fmt" "os" "path/filepath" "time" "github.com/BurntSushi/toml" ) // Config holds the user-tunable settings glint reads at startup. type Config struct { VaultDir string `toml:"vault_dir"` // the vault `glint -v` opens from anywhere DailySubdir string `toml:"daily_subdir"` DailyFormat string `toml:"daily_format"` GlamourStyle string `toml:"glamour_style"` Theme string `toml:"theme"` InboxDir string `toml:"inbox_dir"` Spellcheck string `toml:"spellcheck"` // auto | on | off (TASK-020) // PDF/printable export fonts (TASK-021). CSS font-family stacks that // override the house-style --font-* tokens. Defaults are portable // open/system faces — glint ships to people without the kit's licensed // fonts, so nothing licensed is baked in. FontDisplay string `toml:"font_display"` // headings / cover FontBody string `toml:"font_body"` // body prose FontMono string `toml:"font_mono"` // code } // Default returns the built-in configuration used when no file is present. // VaultDir is empty by default: bare glint works in the current directory; set // vault_dir (config) to give `glint -v` a fixed vault to open from anywhere. func Default() Config { return Config{ DailySubdir: "Daily", DailyFormat: "2006-01-02", Theme: "auto", Spellcheck: "auto", FontDisplay: "Georgia, \"Times New Roman\", serif", FontBody: "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", FontMono: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", } } // WorkingDir is where bare `glint`, `glint -n`, and `-d` operate: the // $GLINT_VAULT pin if set, otherwise the current working directory. func (c Config) WorkingDir() string { if v := os.Getenv("GLINT_VAULT"); v != "" { return v } if wd, err := os.Getwd(); err == nil { return wd } return "." } // Vault is the directory `glint -v` opens from anywhere: the configured // vault_dir, or the working directory when none is set. func (c Config) Vault() string { if c.VaultDir != "" { return c.VaultDir } return c.WorkingDir() } // Path is the config file location: ~/.config/glint/config.toml. func Path() string { home, err := os.UserHomeDir() if err != nil { return filepath.Join(".config", "glint", "config.toml") } return filepath.Join(home, ".config", "glint", "config.toml") } // DictPath is the personal spellcheck dictionary location: // ~/.config/glint/dict.txt (TASK-020). func DictPath() string { home, err := os.UserHomeDir() if err != nil { return filepath.Join(".config", "glint", "dict.txt") } return filepath.Join(home, ".config", "glint", "dict.txt") } // Load reads the config file and overlays it onto the defaults. func Load() (Config, error) { return loadFromFile(Path()) } // Save writes cfg to path as TOML, creating the parent directory. func Save(cfg Config, path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } f, err := os.Create(path) if err != nil { return err } if err := toml.NewEncoder(f).Encode(cfg); err != nil { _ = f.Close() return err } return f.Close() } // loadFromFile is the testable core of Load. A missing file yields the // defaults with no error; a malformed file yields the defaults plus an error. func loadFromFile(path string) (Config, error) { cfg := Default() data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return cfg, nil // absent config is fine } return cfg, fmt.Errorf("read %s: %w", path, err) } var fileCfg Config if _, err := toml.Decode(string(data), &fileCfg); err != nil { return cfg, fmt.Errorf("parse %s: %w", path, err) } if fileCfg.VaultDir != "" { cfg.VaultDir = fileCfg.VaultDir } if fileCfg.DailySubdir != "" { cfg.DailySubdir = fileCfg.DailySubdir } if fileCfg.DailyFormat != "" { cfg.DailyFormat = fileCfg.DailyFormat } if fileCfg.GlamourStyle != "" { cfg.GlamourStyle = fileCfg.GlamourStyle } if fileCfg.Theme != "" { cfg.Theme = fileCfg.Theme } if fileCfg.InboxDir != "" { cfg.InboxDir = fileCfg.InboxDir } if fileCfg.Spellcheck != "" { cfg.Spellcheck = fileCfg.Spellcheck } if fileCfg.FontDisplay != "" { cfg.FontDisplay = fileCfg.FontDisplay } if fileCfg.FontBody != "" { cfg.FontBody = fileCfg.FontBody } if fileCfg.FontMono != "" { cfg.FontMono = fileCfg.FontMono } return cfg, nil } // InboxRoot is the directory new notes default into: the working directory when // InboxDir is empty, an absolute InboxDir as-is, or InboxDir resolved under the // working directory when relative. func (c Config) InboxRoot() string { if c.InboxDir == "" { return c.WorkingDir() } if filepath.IsAbs(c.InboxDir) { return c.InboxDir } return filepath.Join(c.WorkingDir(), c.InboxDir) } // DailyDir is the folder daily notes live in (for the daily-folder picker): an // absolute daily_subdir as-is, else daily_subdir under the vault. func (c Config) DailyDir() string { if filepath.IsAbs(c.DailySubdir) { return c.DailySubdir } return filepath.Join(c.Vault(), c.DailySubdir) } // DailyPath builds the path to the daily note for time t. Daily notes belong to // the vault (Vault()), not the working directory, so `glint --daily` lands in // the same place from anywhere. An absolute daily_subdir is used as-is. func (c Config) DailyPath(t time.Time) string { name := t.Format(c.DailyFormat) + ".md" if filepath.IsAbs(c.DailySubdir) { return filepath.Join(c.DailySubdir, name) } return filepath.Join(c.Vault(), c.DailySubdir, name) }