aerc/config/config.go
Sijmen db39ca181a dirtree: add dirtree-collapse config setting
Adds a setting to the configuration to choose at which level the
folders in the dirtree are collapsed by default.

In my case, this is useful because my organisation has some rather deep
nesting in the folder structure, and a _lot_ of folders, and this way I
can keep my dirtree uncluttered while still having all folders there if
I need them.

Signed-off-by: Sijmen <me@sijman.nl>
Acked-by: Koni Marti <koni.marti@gmail.com>
2022-08-22 09:45:02 +02:00

1091 lines
29 KiB
Go

package config
import (
"errors"
"fmt"
"log"
"net/url"
"os"
"os/exec"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/gdamore/tcell/v2"
"github.com/go-ini/ini"
"github.com/imdario/mergo"
"github.com/kyoh86/xdg"
"github.com/mitchellh/go-homedir"
"git.sr.ht/~rjarry/aerc/lib/templates"
"git.sr.ht/~rjarry/aerc/logging"
)
type GeneralConfig struct {
DefaultSavePath string `ini:"default-save-path"`
PgpProvider string `ini:"pgp-provider"`
UnsafeAccountsConf bool `ini:"unsafe-accounts-conf"`
}
type UIConfig struct {
IndexFormat string `ini:"index-format"`
TimestampFormat string `ini:"timestamp-format"`
ThisDayTimeFormat string `ini:"this-day-time-format"`
ThisWeekTimeFormat string `ini:"this-week-time-format"`
ThisYearTimeFormat string `ini:"this-year-time-format"`
ShowHeaders []string `delim:","`
RenderAccountTabs string `ini:"render-account-tabs"`
PinnedTabMarker string `ini:"pinned-tab-marker"`
SidebarWidth int `ini:"sidebar-width"`
PreviewHeight int `ini:"preview-height"`
EmptyMessage string `ini:"empty-message"`
EmptyDirlist string `ini:"empty-dirlist"`
MouseEnabled bool `ini:"mouse-enabled"`
ThreadingEnabled bool `ini:"threading-enabled"`
ForceClientThreads bool `ini:"force-client-threads"`
ClientThreadsDelay time.Duration `ini:"client-threads-delay"`
FuzzyComplete bool `ini:"fuzzy-complete"`
NewMessageBell bool `ini:"new-message-bell"`
Spinner string `ini:"spinner"`
SpinnerDelimiter string `ini:"spinner-delimiter"`
IconUnencrypted string `ini:"icon-unencrypted"`
IconEncrypted string `ini:"icon-encrypted"`
IconSigned string `ini:"icon-signed"`
IconSignedEncrypted string `ini:"icon-signed-encrypted"`
IconUnknown string `ini:"icon-unknown"`
IconInvalid string `ini:"icon-invalid"`
DirListFormat string `ini:"dirlist-format"`
DirListDelay time.Duration `ini:"dirlist-delay"`
DirListTree bool `ini:"dirlist-tree"`
DirListCollapse int `ini:"dirlist-collapse"`
Sort []string `delim:" "`
NextMessageOnDelete bool `ini:"next-message-on-delete"`
CompletionDelay time.Duration `ini:"completion-delay"`
CompletionPopovers bool `ini:"completion-popovers"`
StyleSetDirs []string `ini:"stylesets-dirs" delim:":"`
StyleSetName string `ini:"styleset-name"`
style StyleSet
// customize border appearance
BorderCharVertical rune `ini:"-"`
BorderCharHorizontal rune `ini:"-"`
}
type ContextType int
const (
UI_CONTEXT_FOLDER ContextType = iota
UI_CONTEXT_ACCOUNT
UI_CONTEXT_SUBJECT
BIND_CONTEXT_ACCOUNT
BIND_CONTEXT_FOLDER
)
type UIConfigContext struct {
ContextType ContextType
Regex *regexp.Regexp
UiConfig UIConfig
}
const (
FILTER_MIMETYPE = iota
FILTER_HEADER
)
type RemoteConfig struct {
Value string
PasswordCmd string
}
func (c RemoteConfig) parseValue() (*url.URL, error) {
return url.Parse(c.Value)
}
func (c RemoteConfig) ConnectionString() (string, error) {
if c.Value == "" || c.PasswordCmd == "" {
return c.Value, nil
}
u, err := c.parseValue()
if err != nil {
return "", err
}
// ignore the command if a password is specified
if _, exists := u.User.Password(); exists {
return c.Value, nil
}
// don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail)
if !u.IsAbs() {
return c.Value, nil
}
cmd := exec.Command("sh", "-c", c.PasswordCmd)
cmd.Stdin = os.Stdin
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to read password: %w", err)
}
pw := strings.TrimSpace(string(output))
u.User = url.UserPassword(u.User.Username(), pw)
c.Value = u.String()
return c.Value, nil
}
type AccountConfig struct {
Archive string
CopyTo string
Default string
Postpone string
From string
Aliases string
Name string
Source string
Folders []string
FoldersExclude []string
Params map[string]string
Outgoing RemoteConfig
SignatureFile string
SignatureCmd string
EnableFoldersSort bool `ini:"enable-folders-sort"`
FoldersSort []string `ini:"folders-sort" delim:","`
AddressBookCmd string `ini:"address-book-cmd"`
// CheckMail
CheckMail time.Duration `ini:"check-mail"`
CheckMailCmd string `ini:"check-mail-cmd"`
CheckMailTimeout time.Duration `ini:"check-mail-timeout"`
CheckMailInclude []string `ini:"check-mail-include"`
CheckMailExclude []string `ini:"check-mail-exclude"`
// PGP Config
PgpKeyId string `ini:"pgp-key-id"`
PgpAutoSign bool `ini:"pgp-auto-sign"`
PgpOpportunisticEncrypt bool `ini:"pgp-opportunistic-encrypt"`
// AuthRes
TrustedAuthRes []string `ini:"trusted-authres" delim:","`
}
type BindingConfig struct {
Global *KeyBindings
AccountWizard *KeyBindings
Compose *KeyBindings
ComposeEditor *KeyBindings
ComposeReview *KeyBindings
MessageList *KeyBindings
MessageView *KeyBindings
MessageViewPassthrough *KeyBindings
Terminal *KeyBindings
}
type BindingConfigContext struct {
ContextType ContextType
Regex *regexp.Regexp
Bindings *KeyBindings
BindContext string
}
type ComposeConfig struct {
Editor string `ini:"editor"`
HeaderLayout [][]string `ini:"-"`
AddressBookCmd string `ini:"address-book-cmd"`
ReplyToSelf bool `ini:"reply-to-self"`
}
type FilterConfig struct {
FilterType int
Filter string
Command string
Header string
Regex *regexp.Regexp
}
type ViewerConfig struct {
Pager string
Alternatives []string
ShowHeaders bool `ini:"show-headers"`
AlwaysShowMime bool `ini:"always-show-mime"`
ParseHttpLinks bool `ini:"parse-http-links"`
HeaderLayout [][]string `ini:"-"`
KeyPassthrough bool `ini:"-"`
}
type StatuslineConfig struct {
RenderFormat string `ini:"render-format"`
Separator string
DisplayMode string `ini:"display-mode"`
}
type TriggersConfig struct {
NewEmail string `ini:"new-email"`
ExecuteCommand func(command []string) error
}
type TemplateConfig struct {
TemplateDirs []string `ini:"template-dirs" delim:":"`
NewMessage string `ini:"new-message"`
QuotedReply string `ini:"quoted-reply"`
Forwards string `ini:"forwards"`
}
type AercConfig struct {
Bindings BindingConfig
ContextualBinds []BindingConfigContext
Compose ComposeConfig
Ini *ini.File `ini:"-"`
Accounts []AccountConfig `ini:"-"`
Filters []FilterConfig `ini:"-"`
Viewer ViewerConfig `ini:"-"`
Statusline StatuslineConfig `ini:"-"`
Triggers TriggersConfig `ini:"-"`
Ui UIConfig
ContextualUis []UIConfigContext
General GeneralConfig
Templates TemplateConfig
}
// Input: TimestampFormat
// Output: timestamp-format
func mapName(raw string) string {
newstr := make([]rune, 0, len(raw))
for i, chr := range raw {
if isUpper := 'A' <= chr && chr <= 'Z'; isUpper {
if i > 0 {
newstr = append(newstr, '-')
}
}
newstr = append(newstr, unicode.ToLower(chr))
}
return string(newstr)
}
func loadAccountConfig(path string) ([]AccountConfig, error) {
file, err := ini.Load(path)
if err != nil {
// No config triggers account configuration wizard
return nil, nil
}
file.NameMapper = mapName
var accounts []AccountConfig
for _, _sec := range file.SectionStrings() {
if _sec == "DEFAULT" {
continue
}
sec := file.Section(_sec)
sourceRemoteConfig := RemoteConfig{}
account := AccountConfig{
Archive: "Archive",
Default: "INBOX",
Postpone: "Drafts",
Name: _sec,
Params: make(map[string]string),
EnableFoldersSort: true,
CheckMailTimeout: 10 * time.Second,
}
if err = sec.MapTo(&account); err != nil {
return nil, err
}
for key, val := range sec.KeysHash() {
switch key {
case "folders":
folders := strings.Split(val, ",")
sort.Strings(folders)
account.Folders = folders
case "folders-exclude":
folders := strings.Split(val, ",")
sort.Strings(folders)
account.FoldersExclude = folders
case "source":
sourceRemoteConfig.Value = val
case "source-cred-cmd":
sourceRemoteConfig.PasswordCmd = val
case "outgoing":
account.Outgoing.Value = val
case "outgoing-cred-cmd":
account.Outgoing.PasswordCmd = val
case "from":
account.From = val
case "aliases":
account.Aliases = val
case "copy-to":
account.CopyTo = val
case "archive":
account.Archive = val
case "enable-folders-sort":
account.EnableFoldersSort, _ = strconv.ParseBool(val)
case "pgp-key-id":
account.PgpKeyId = val
case "pgp-auto-sign":
account.PgpAutoSign, _ = strconv.ParseBool(val)
case "pgp-opportunistic-encrypt":
account.PgpOpportunisticEncrypt, _ = strconv.ParseBool(val)
case "address-book-cmd":
account.AddressBookCmd = val
default:
if key != "name" {
account.Params[key] = val
}
}
}
if account.Source == "" {
return nil, fmt.Errorf("Expected source for account %s", _sec)
}
if account.From == "" {
return nil, fmt.Errorf("Expected from for account %s", _sec)
}
source, err := sourceRemoteConfig.ConnectionString()
if err != nil {
return nil, fmt.Errorf("Invalid source credentials for %s: %w", _sec, err)
}
account.Source = source
_, err = account.Outgoing.parseValue()
if err != nil {
return nil, fmt.Errorf("Invalid outgoing credentials for %s: %w", _sec, err)
}
accounts = append(accounts, account)
}
return accounts, nil
}
// Set at build time
var shareDir string
func buildDefaultDirs() []string {
var defaultDirs []string
prefixes := []string{
xdg.ConfigHome(),
xdg.DataHome(),
}
// Add XDG_CONFIG_HOME and XDG_DATA_HOME
for _, v := range prefixes {
if v != "" {
v, err := homedir.Expand(v)
if err != nil {
log.Println(err)
}
defaultDirs = append(defaultDirs, path.Join(v, "aerc"))
}
}
// Add custom buildtime shareDir
if shareDir != "" && shareDir != "/usr/local/share/aerc" {
shareDir, err := homedir.Expand(shareDir)
if err == nil {
defaultDirs = append(defaultDirs, shareDir)
}
}
// Add fixed fallback locations
defaultDirs = append(defaultDirs, "/usr/local/share/aerc")
defaultDirs = append(defaultDirs, "/usr/share/aerc")
return defaultDirs
}
var searchDirs = buildDefaultDirs()
func installTemplate(root, name string) error {
var err error
if _, err = os.Stat(root); os.IsNotExist(err) {
err = os.MkdirAll(root, 0o755)
if err != nil {
return err
}
}
var data []byte
for _, dir := range searchDirs {
data, err = os.ReadFile(path.Join(dir, name))
if err == nil {
break
}
}
if err != nil {
return err
}
err = os.WriteFile(path.Join(root, name), data, 0o644)
if err != nil {
return err
}
return nil
}
func (config *AercConfig) LoadConfig(file *ini.File) error {
if filters, err := file.GetSection("filters"); err == nil {
// TODO: Parse the filter more finely, e.g. parse the regex
for _, match := range filters.KeyStrings() {
cmd := filters.KeysHash()[match]
filter := FilterConfig{
Command: cmd,
Filter: match,
}
switch {
case strings.Contains(match, ",~"):
filter.FilterType = FILTER_HEADER
header := filter.Filter[:strings.Index(filter.Filter, ",")] //nolint:gocritic // guarded by strings.Contains
regex := filter.Filter[strings.Index(filter.Filter, "~")+1:]
filter.Header = strings.ToLower(header)
filter.Regex, err = regexp.Compile(regex)
if err != nil {
return err
}
case strings.ContainsRune(match, ','):
filter.FilterType = FILTER_HEADER
header := filter.Filter[:strings.Index(filter.Filter, ",")] //nolint:gocritic // guarded by strings.Contains
value := filter.Filter[strings.Index(filter.Filter, ",")+1:]
filter.Header = strings.ToLower(header)
filter.Regex, err = regexp.Compile(regexp.QuoteMeta(value))
if err != nil {
return err
}
default:
filter.FilterType = FILTER_MIMETYPE
}
config.Filters = append(config.Filters, filter)
}
}
if viewer, err := file.GetSection("viewer"); err == nil {
if err := viewer.MapTo(&config.Viewer); err != nil {
return err
}
for key, val := range viewer.KeysHash() {
switch key {
case "alternatives":
config.Viewer.Alternatives = strings.Split(val, ",")
case "header-layout":
config.Viewer.HeaderLayout = parseLayout(val)
}
}
}
if statusline, err := file.GetSection("statusline"); err == nil {
if err := statusline.MapTo(&config.Statusline); err != nil {
return err
}
}
if compose, err := file.GetSection("compose"); err == nil {
if err := compose.MapTo(&config.Compose); err != nil {
return err
}
for key, val := range compose.KeysHash() {
if key == "header-layout" {
config.Compose.HeaderLayout = parseLayout(val)
}
}
}
if ui, err := file.GetSection("ui"); err == nil {
if err := ui.MapTo(&config.Ui); err != nil {
return err
}
if err := validateBorderChars(ui, &config.Ui); err != nil {
return err
}
// Values with type=time.Duration must be explicitly set. If these
// values are given a default in the struct passed to ui.MapTo, which
// they are, a zero-value in the config won't overwrite the default.
for key, val := range ui.KeysHash() {
switch key {
case "dirlist-delay":
dur, err := time.ParseDuration(val)
if err != nil {
return err
}
config.Ui.DirListDelay = dur
case "completion-delay":
dur, err := time.ParseDuration(val)
if err != nil {
return err
}
config.Ui.CompletionDelay = dur
}
}
}
for _, sectionName := range file.SectionStrings() {
if !strings.Contains(sectionName, "ui:") {
continue
}
uiSection, err := file.GetSection(sectionName)
if err != nil {
return err
}
uiSubConfig := UIConfig{}
if err := uiSection.MapTo(&uiSubConfig); err != nil {
return err
}
if err := validateBorderChars(uiSection, &uiSubConfig); err != nil {
return err
}
contextualUi := UIConfigContext{
UiConfig: uiSubConfig,
}
var index int
switch {
case strings.Contains(sectionName, "~"):
index = strings.Index(sectionName, "~")
regex := string(sectionName[index+1:])
contextualUi.Regex, err = regexp.Compile(regex)
if err != nil {
return err
}
case strings.Contains(sectionName, "="):
index = strings.Index(sectionName, "=")
value := string(sectionName[index+1:])
contextualUi.Regex, err = regexp.Compile(regexp.QuoteMeta(value))
if err != nil {
return err
}
default:
return fmt.Errorf("Invalid Ui Context regex in %s", sectionName)
}
switch sectionName[3:index] {
case "account":
contextualUi.ContextType = UI_CONTEXT_ACCOUNT
case "folder":
contextualUi.ContextType = UI_CONTEXT_FOLDER
case "subject":
contextualUi.ContextType = UI_CONTEXT_SUBJECT
default:
return fmt.Errorf("Unknown Contextual Ui Section: %s", sectionName)
}
config.ContextualUis = append(config.ContextualUis, contextualUi)
}
if triggers, err := file.GetSection("triggers"); err == nil {
if err := triggers.MapTo(&config.Triggers); err != nil {
return err
}
}
if templatesSec, err := file.GetSection("templates"); err == nil {
if err := templatesSec.MapTo(&config.Templates); err != nil {
return err
}
templateDirs := templatesSec.Key("template-dirs").String()
if templateDirs != "" {
config.Templates.TemplateDirs = strings.Split(templateDirs, ":")
}
}
// append default paths to template-dirs and styleset-dirs
for _, dir := range searchDirs {
config.Ui.StyleSetDirs = append(
config.Ui.StyleSetDirs, path.Join(dir, "stylesets"),
)
config.Templates.TemplateDirs = append(
config.Templates.TemplateDirs, path.Join(dir, "templates"),
)
}
// we want to fail during startup if the templates are not ok
// hence we do dummy executes here
t := config.Templates
if err := templates.CheckTemplate(t.NewMessage, t.TemplateDirs); err != nil {
return err
}
if err := templates.CheckTemplate(t.QuotedReply, t.TemplateDirs); err != nil {
return err
}
if err := templates.CheckTemplate(t.Forwards, t.TemplateDirs); err != nil {
return err
}
if err := config.Ui.loadStyleSet(
config.Ui.StyleSetDirs); err != nil {
return err
}
for idx, contextualUi := range config.ContextualUis {
if contextualUi.UiConfig.StyleSetName == "" &&
len(contextualUi.UiConfig.StyleSetDirs) == 0 {
continue // no need to do anything if nothing is overridden
}
// fill in the missing part from the base
if contextualUi.UiConfig.StyleSetName == "" {
config.ContextualUis[idx].UiConfig.StyleSetName = config.Ui.StyleSetName
} else if len(contextualUi.UiConfig.StyleSetDirs) == 0 {
config.ContextualUis[idx].UiConfig.StyleSetDirs = config.Ui.StyleSetDirs
}
// since at least one of them has changed, load the styleset
if err := config.ContextualUis[idx].UiConfig.loadStyleSet(
config.ContextualUis[idx].UiConfig.StyleSetDirs); err != nil {
return err
}
}
return nil
}
func validateBorderChars(section *ini.Section, config *UIConfig) error {
for key, val := range section.KeysHash() {
switch key {
case "border-char-vertical":
char := []rune(val)
if len(char) != 1 {
return fmt.Errorf("%v must be one and only one character", key)
}
config.BorderCharVertical = char[0]
case "border-char-horizontal":
char := []rune(val)
if len(char) != 1 {
return fmt.Errorf("%v must be one and only one character", key)
}
config.BorderCharHorizontal = char[0]
}
}
return nil
}
func validatePgpProvider(section *ini.Section) error {
m := map[string]bool{
"gpg": true,
"internal": true,
}
for key, val := range section.KeysHash() {
if key == "pgp-provider" {
if !m[strings.ToLower(val)] {
return fmt.Errorf("%v must be either 'gpg' or 'internal'", key)
}
}
}
return nil
}
func LoadConfigFromFile(root *string) (*AercConfig, error) {
if root == nil {
_root := path.Join(xdg.ConfigHome(), "aerc")
root = &_root
}
filename := path.Join(*root, "aerc.conf")
// if it doesn't exist copy over the template, then load
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
logging.Debugf("%s not found, installing the system default", filename)
if err := installTemplate(*root, "aerc.conf"); err != nil {
return nil, err
}
}
logging.Infof("Parsing configuration from %s", filename)
file, err := ini.LoadSources(ini.LoadOptions{
KeyValueDelimiters: "=",
}, filename)
if err != nil {
return nil, err
}
file.NameMapper = mapName
config := &AercConfig{
Bindings: BindingConfig{
Global: NewKeyBindings(),
AccountWizard: NewKeyBindings(),
Compose: NewKeyBindings(),
ComposeEditor: NewKeyBindings(),
ComposeReview: NewKeyBindings(),
MessageList: NewKeyBindings(),
MessageView: NewKeyBindings(),
MessageViewPassthrough: NewKeyBindings(),
Terminal: NewKeyBindings(),
},
ContextualBinds: []BindingConfigContext{},
Ini: file,
General: GeneralConfig{
PgpProvider: "internal",
UnsafeAccountsConf: false,
},
Ui: UIConfig{
IndexFormat: "%D %-17.17n %s",
TimestampFormat: "2006-01-02 03:04 PM",
ThisDayTimeFormat: "",
ThisWeekTimeFormat: "",
ThisYearTimeFormat: "",
ShowHeaders: []string{
"From", "To", "Cc", "Bcc", "Subject", "Date",
},
RenderAccountTabs: "auto",
PinnedTabMarker: "`",
SidebarWidth: 20,
PreviewHeight: 12,
EmptyMessage: "(no messages)",
EmptyDirlist: "(no folders)",
MouseEnabled: false,
ClientThreadsDelay: 50 * time.Millisecond,
NewMessageBell: true,
FuzzyComplete: false,
Spinner: "[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] ",
SpinnerDelimiter: ",",
IconUnencrypted: "",
IconSigned: "[s]",
IconEncrypted: "[e]",
IconSignedEncrypted: "",
IconUnknown: "[s?]",
IconInvalid: "[s!]",
DirListFormat: "%n %>r",
DirListDelay: 200 * time.Millisecond,
NextMessageOnDelete: true,
CompletionDelay: 250 * time.Millisecond,
CompletionPopovers: true,
StyleSetDirs: []string{},
StyleSetName: "default",
// border defaults
BorderCharVertical: ' ',
BorderCharHorizontal: ' ',
},
ContextualUis: []UIConfigContext{},
Viewer: ViewerConfig{
Pager: "less -R",
Alternatives: []string{"text/plain", "text/html"},
ShowHeaders: false,
HeaderLayout: [][]string{
{"From", "To"},
{"Cc", "Bcc"},
{"Date"},
{"Subject"},
},
ParseHttpLinks: true,
},
Statusline: StatuslineConfig{
RenderFormat: "[%a] %S %>%T",
Separator: " | ",
DisplayMode: "",
},
Compose: ComposeConfig{
HeaderLayout: [][]string{
{"To", "From"},
{"Subject"},
},
ReplyToSelf: true,
},
Templates: TemplateConfig{
TemplateDirs: []string{},
NewMessage: "new_message",
QuotedReply: "quoted_reply",
Forwards: "forward_as_body",
},
}
// These bindings are not configurable
config.Bindings.AccountWizard.ExKey = KeyStroke{
Key: tcell.KeyCtrlE,
}
quit, _ := ParseBinding("<C-q>", ":quit<Enter>")
config.Bindings.AccountWizard.Add(quit)
if err = config.LoadConfig(file); err != nil {
return nil, err
}
if ui, err := file.GetSection("general"); err == nil {
if err := ui.MapTo(&config.General); err != nil {
return nil, err
}
if err := validatePgpProvider(ui); err != nil {
return nil, err
}
}
logging.Debugf("aerc.conf: [general] %#v", config.General)
logging.Debugf("aerc.conf: [ui] %#v", config.Ui)
logging.Debugf("aerc.conf: [statusline] %#v", config.Statusline)
logging.Debugf("aerc.conf: [viewer] %#v", config.Viewer)
logging.Debugf("aerc.conf: [compose] %#v", config.Compose)
logging.Debugf("aerc.conf: [filters] %#v", config.Filters)
logging.Debugf("aerc.conf: [triggers] %#v", config.Triggers)
logging.Debugf("aerc.conf: [templates] %#v", config.Templates)
filename = path.Join(*root, "accounts.conf")
if !config.General.UnsafeAccountsConf {
if err := checkConfigPerms(filename); err != nil {
return nil, err
}
}
accountsPath := path.Join(*root, "accounts.conf")
logging.Infof("Parsing accounts configuration from %s", accountsPath)
if accounts, err := loadAccountConfig(accountsPath); err != nil {
return nil, err
} else {
config.Accounts = accounts
}
for _, acct := range config.Accounts {
logging.Debugf("accounts.conf: [%s] from = %s", acct.Name, acct.From)
}
filename = path.Join(*root, "binds.conf")
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
logging.Debugf("%s not found, installing the system default", filename)
if err := installTemplate(*root, "binds.conf"); err != nil {
return nil, err
}
}
logging.Infof("Parsing key bindings configuration from %s", filename)
binds, err := ini.Load(filename)
if err != nil {
return nil, err
}
baseGroups := map[string]**KeyBindings{
"default": &config.Bindings.Global,
"compose": &config.Bindings.Compose,
"messages": &config.Bindings.MessageList,
"terminal": &config.Bindings.Terminal,
"view": &config.Bindings.MessageView,
"view::passthrough": &config.Bindings.MessageViewPassthrough,
"compose::editor": &config.Bindings.ComposeEditor,
"compose::review": &config.Bindings.ComposeReview,
}
// Base Bindings
for _, sectionName := range binds.SectionStrings() {
// Handle :: delimeter
baseSectionName := strings.ReplaceAll(sectionName, "::", "////")
sections := strings.Split(baseSectionName, ":")
baseOnly := len(sections) == 1
baseSectionName = strings.ReplaceAll(sections[0], "////", "::")
group, ok := baseGroups[strings.ToLower(baseSectionName)]
if !ok {
return nil, errors.New("Unknown keybinding group " + sectionName)
}
if baseOnly {
err = config.LoadBinds(binds, baseSectionName, group)
if err != nil {
return nil, err
}
}
}
config.Bindings.Global.Globals = false
for _, contextBind := range config.ContextualBinds {
if contextBind.BindContext == "default" {
contextBind.Bindings.Globals = false
}
}
logging.Debugf("binds.conf: %#v", config.Bindings)
return config, nil
}
func LoadBindingSection(sec *ini.Section) (*KeyBindings, error) {
bindings := NewKeyBindings()
for key, value := range sec.KeysHash() {
if key == "$ex" {
strokes, err := ParseKeyStrokes(value)
if err != nil {
return nil, err
}
if len(strokes) != 1 {
return nil, errors.New("Invalid binding")
}
bindings.ExKey = strokes[0]
continue
}
if key == "$noinherit" {
if value == "false" {
continue
}
if value != "true" {
return nil, errors.New("Invalid binding")
}
bindings.Globals = false
continue
}
binding, err := ParseBinding(key, value)
if err != nil {
return nil, err
}
bindings.Add(binding)
}
return bindings, nil
}
func (config *AercConfig) LoadBinds(binds *ini.File, baseName string, baseGroup **KeyBindings) error {
if sec, err := binds.GetSection(baseName); err == nil {
binds, err := LoadBindingSection(sec)
if err != nil {
return err
}
*baseGroup = MergeBindings(binds, *baseGroup)
}
for _, sectionName := range binds.SectionStrings() {
if !strings.Contains(sectionName, baseName+":") ||
strings.Contains(sectionName, baseName+"::") {
continue
}
bindSection, err := binds.GetSection(sectionName)
if err != nil {
return err
}
binds, err := LoadBindingSection(bindSection)
if err != nil {
return err
}
contextualBind := BindingConfigContext{
Bindings: binds,
BindContext: baseName,
}
var index int
if strings.Contains(sectionName, "=") {
index = strings.Index(sectionName, "=")
value := string(sectionName[index+1:])
contextualBind.Regex, err = regexp.Compile(value)
if err != nil {
return err
}
} else {
return fmt.Errorf("Invalid Bind Context regex in %s", sectionName)
}
switch sectionName[len(baseName)+1 : index] {
case "account":
acctName := sectionName[index+1:]
valid := false
for _, acctConf := range config.Accounts {
matches := contextualBind.Regex.FindString(acctConf.Name)
if matches != "" {
valid = true
}
}
if !valid {
logging.Warnf("binds.conf: unexistent account: %s", acctName)
continue
}
contextualBind.ContextType = BIND_CONTEXT_ACCOUNT
case "folder":
// No validation needed. If the folder doesn't exist, the binds
// never get used
contextualBind.ContextType = BIND_CONTEXT_FOLDER
default:
return fmt.Errorf("Unknown Context Bind Section: %s", sectionName)
}
config.ContextualBinds = append(config.ContextualBinds, contextualBind)
}
return nil
}
// checkConfigPerms checks for too open permissions
// printing the fix on stdout and returning an error
func checkConfigPerms(filename string) error {
info, err := os.Stat(filename)
if errors.Is(err, os.ErrNotExist) {
return nil // disregard absent files
}
if err != nil {
return err
}
perms := info.Mode().Perm()
// group or others have read access
if perms&0o44 != 0 {
fmt.Fprintf(os.Stderr, "The file %v has too open permissions.\n", filename)
fmt.Fprintln(os.Stderr, "This is a security issue (it contains passwords).")
fmt.Fprintf(os.Stderr, "To fix it, run `chmod 600 %v`\n", filename)
return errors.New("account.conf permissions too lax")
}
return nil
}
func parseLayout(layout string) [][]string {
rows := strings.Split(layout, ",")
l := make([][]string, len(rows))
for i, r := range rows {
l[i] = strings.Split(r, "|")
}
return l
}
func (ui *UIConfig) loadStyleSet(styleSetDirs []string) error {
ui.style = NewStyleSet()
err := ui.style.LoadStyleSet(ui.StyleSetName, styleSetDirs)
if err != nil {
return fmt.Errorf("Unable to load default styleset: %w", err)
}
return nil
}
func (config AercConfig) mergeContextualUi(baseUi UIConfig,
contextType ContextType, s string,
) UIConfig {
for _, contextualUi := range config.ContextualUis {
if contextualUi.ContextType != contextType {
continue
}
if !contextualUi.Regex.Match([]byte(s)) {
continue
}
err := mergo.Merge(&baseUi, contextualUi.UiConfig, mergo.WithOverride)
if err != nil {
logging.Warnf("merge ui failed: %v", err)
}
if contextualUi.UiConfig.StyleSetName != "" {
baseUi.style = contextualUi.UiConfig.style
}
return baseUi
}
return baseUi
}
func (config AercConfig) GetUiConfig(params map[ContextType]string) *UIConfig {
baseUi := config.Ui
for k, v := range params {
baseUi = config.mergeContextualUi(baseUi, k, v)
}
return &baseUi
}
func (config *AercConfig) GetContextualUIConfigs() []UIConfigContext {
return config.ContextualUis
}
func (uiConfig UIConfig) GetStyle(so StyleObject) tcell.Style {
return uiConfig.style.Get(so)
}
func (uiConfig UIConfig) GetStyleSelected(so StyleObject) tcell.Style {
return uiConfig.style.Selected(so)
}
func (uiConfig UIConfig) GetComposedStyle(base StyleObject,
styles []StyleObject,
) tcell.Style {
return uiConfig.style.Compose(base, styles)
}
func (uiConfig UIConfig) GetComposedStyleSelected(base StyleObject, styles []StyleObject) tcell.Style {
return uiConfig.style.ComposeSelected(base, styles)
}