diff options
author | Robin Jarry <robin@jarry.cc> | 2023-02-21 16:18:54 +0100 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2023-03-02 23:56:13 +0100 |
commit | b63c93563c622e70cda7006c1816dc6b59e75844 (patch) | |
tree | 821bd7636cf676640e7b391d8ce015bb33f2843e | |
parent | d9a8edd8e9269aa1189d55c8d13caa05084435f5 (diff) | |
download | aerc-b63c93563c622e70cda7006c1816dc6b59e75844.tar.gz |
config: use reflection to map ini keys to struct fields
The default ini.Section.MapTo() function only handles basic types.
Implement a more complete mapping solution that allows:
* parsing templates, regexps, email addresses
* defining a custom parsing method via the `parse:"MethodName"` tag
* defining default values via the `default:"value"` tag
* parsing rune values with the `type:"rune"` tag
The field name must be specified in the `ini:"field-name"` tag as it was
before. It is no longer optional.
The `delim:"<separator>"` tag remains but can only be used to parse
string arrays.
It is now possible to override default values with "zero" values. For
example:
[ui]
dirlist-delay = 0
Will override the default "200ms" value. Also:
[statusline]
status-columns =
Will override the default "left<*,center>=,right>*" value.
Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Tim Culverhouse <tim@timculverhouse.com>
-rw-r--r-- | config/accounts.go | 180 | ||||
-rw-r--r-- | config/compose.go | 54 | ||||
-rw-r--r-- | config/config.go | 17 | ||||
-rw-r--r-- | config/general.go | 46 | ||||
-rw-r--r-- | config/parse.go | 233 | ||||
-rw-r--r-- | config/statusline.go | 77 | ||||
-rw-r--r-- | config/templates.go | 28 | ||||
-rw-r--r-- | config/triggers.go | 61 | ||||
-rw-r--r-- | config/ui.go | 261 | ||||
-rw-r--r-- | config/viewer.go | 49 |
10 files changed, 465 insertions, 541 deletions
diff --git a/config/accounts.go b/config/accounts.go index e9b7c633..8aadb329 100644 --- a/config/accounts.go +++ b/config/accounts.go @@ -10,7 +10,6 @@ import ( "reflect" "regexp" "sort" - "strconv" "strings" "time" @@ -70,30 +69,32 @@ func (c *RemoteConfig) ConnectionString() (string, error) { } type AccountConfig struct { - Archive string `ini:"archive"` - CopyTo string `ini:"copy-to"` - Default string `ini:"default"` - Postpone string `ini:"postpone"` - From *mail.Address `ini:"-"` - Aliases []*mail.Address `ini:"-"` - Name string `ini:"-"` - Source string `ini:"-"` - Folders []string `ini:"folders" delim:","` - FoldersExclude []string `ini:"folders-exclude" delim:","` - Params map[string]string `ini:"-"` - Outgoing RemoteConfig `ini:"-"` - SignatureFile string `ini:"signature-file"` - SignatureCmd string `ini:"signature-cmd"` - EnableFoldersSort bool `ini:"enable-folders-sort"` - FoldersSort []string `ini:"folders-sort" delim:","` - AddressBookCmd string `ini:"address-book-cmd"` - SendAsUTC bool `ini:"send-as-utc"` - LocalizedRe *regexp.Regexp `ini:"-"` + Name string + // backend specific + Params map[string]string + + Archive string `ini:"archive" default:"Archive"` + CopyTo string `ini:"copy-to"` + Default string `ini:"default" default:"INBOX"` + Postpone string `ini:"postpone" default:"Drafts"` + From *mail.Address `ini:"from"` + Aliases []*mail.Address `ini:"aliases"` + Source string `ini:"source" parse:"ParseSource"` + Folders []string `ini:"folders" delim:","` + FoldersExclude []string `ini:"folders-exclude" delim:","` + Outgoing RemoteConfig `ini:"outgoing" parse:"ParseOutgoing"` + SignatureFile string `ini:"signature-file"` + SignatureCmd string `ini:"signature-cmd"` + EnableFoldersSort bool `ini:"enable-folders-sort" default:"true"` + FoldersSort []string `ini:"folders-sort" delim:","` + AddressBookCmd string `ini:"address-book-cmd"` + SendAsUTC bool `ini:"send-as-utc" default:"false"` + LocalizedRe *regexp.Regexp `ini:"subject-re-pattern" default:"(?i)^((AW|RE|SV|VS|ODP|R): ?)+"` // CheckMail CheckMail time.Duration `ini:"check-mail"` CheckMailCmd string `ini:"check-mail-cmd"` - CheckMailTimeout time.Duration `ini:"check-mail-timeout"` + CheckMailTimeout time.Duration `ini:"check-mail-timeout" default:"10s"` CheckMailInclude []string `ini:"check-mail-include"` CheckMailExclude []string `ini:"check-mail-exclude"` @@ -101,7 +102,7 @@ type AccountConfig struct { PgpKeyId string `ini:"pgp-key-id"` PgpAutoSign bool `ini:"pgp-auto-sign"` PgpOpportunisticEncrypt bool `ini:"pgp-opportunistic-encrypt"` - PgpErrorLevel int `ini:"-"` + PgpErrorLevel int `ini:"pgp-error-level" parse:"ParsePgpErrorLevel" default:"warn"` // AuthRes TrustedAuthRes []string `ini:"trusted-authres" delim:","` @@ -130,7 +131,6 @@ func parseAccounts(root string, accts []string) error { // No config triggers account configuration wizard return nil } - file.NameMapper = mapName for _, _sec := range file.SectionStrings() { if _sec == "DEFAULT" { @@ -140,88 +140,27 @@ func parseAccounts(root string, accts []string) error { 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, - PgpErrorLevel: PgpErrorLevelWarn, - // localizedRe contains a list of known translations for the common Re: - LocalizedRe: regexp.MustCompile(`(?i)^((AW|RE|SV|VS|ODP|R): ?)+`), + Name: _sec, + Params: make(map[string]string), } - if err = sec.MapTo(&account); err != nil { + if err = MapToStruct(sec, &account, true); err != nil { return err } for key, val := range sec.KeysHash() { - switch key { - 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 "outgoing-cred-cmd-cache": - cache, err := strconv.ParseBool(val) - if err != nil { - return fmt.Errorf("%s=%s %w", key, val, err) - } - account.Outgoing.CacheCmd = cache - case "from": - addr, err := mail.ParseAddress(val) - if err != nil { - return fmt.Errorf("%s=%s %w", key, val, err) - } - account.From = addr - case "aliases": - addrs, err := mail.ParseAddressList(val) - if err != nil { - return fmt.Errorf("%s=%s %w", key, val, err) - } - account.Aliases = addrs - case "subject-re-pattern": - re, err := regexp.Compile(val) - if err != nil { - return fmt.Errorf("%s=%s %w", key, val, err) - } - account.LocalizedRe = re - case "pgp-error-level": - switch strings.ToLower(val) { - case "none": - account.PgpErrorLevel = PgpErrorLevelNone - case "warn": - account.PgpErrorLevel = PgpErrorLevelWarn - case "error": - account.PgpErrorLevel = PgpErrorLevelError - default: - return fmt.Errorf("unknown pgp-error-level: %s", val) - } - default: - backendSpecific := true - typ := reflect.TypeOf(account) - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - if field.Tag.Get("ini") == key { - backendSpecific = false - break - } - } - if backendSpecific { - account.Params[key] = val + backendSpecific := true + typ := reflect.TypeOf(account) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Tag.Get("ini") == key { + backendSpecific = false + break } } + if backendSpecific { + account.Params[key] = val + } } - source, err := sourceRemoteConfig.ConnectionString() - if err != nil { - return fmt.Errorf("Invalid source credentials for %s: %w", _sec, err) - } - account.Source = source - if account.Source == "" { return fmt.Errorf("Expected source for account %s", _sec) } @@ -229,11 +168,6 @@ func parseAccounts(root string, accts []string) error { return fmt.Errorf("Expected from for account %s", _sec) } - _, err = account.Outgoing.parseValue() - if err != nil { - return fmt.Errorf("Invalid outgoing credentials for %s: %w", _sec, err) - } - log.Debugf("accounts.conf: [%s] from = %s", account.Name, account.From) Accounts = append(Accounts, &account) } @@ -251,6 +185,48 @@ func parseAccounts(root string, accts []string) error { return nil } +func (a *AccountConfig) ParseSource(sec *ini.Section, key *ini.Key) (string, error) { + var remote RemoteConfig + remote.Value = key.String() + if k, err := sec.GetKey("source-cred-cmd"); err == nil { + remote.PasswordCmd = k.String() + } + return remote.ConnectionString() +} + +func (a *AccountConfig) ParseOutgoing(sec *ini.Section, key *ini.Key) (RemoteConfig, error) { + var remote RemoteConfig + remote.Value = key.String() + if k, err := sec.GetKey("outgoing-cred-cmd"); err == nil { + remote.PasswordCmd = k.String() + } + if k, err := sec.GetKey("outgoing-cred-cmd-cache"); err == nil { + cache, err := k.Bool() + if err != nil { + return remote, err + } + remote.CacheCmd = cache + } + _, err := remote.parseValue() + return remote, err +} + +func (a *AccountConfig) ParsePgpErrorLevel(sec *ini.Section, key *ini.Key) (int, error) { + var level int + var err error + switch strings.ToLower(key.String()) { + case "none": + level = PgpErrorLevelNone + case "warn": + level = PgpErrorLevelWarn + case "error": + level = PgpErrorLevelError + default: + err = fmt.Errorf("unknown level: %s", key.String()) + } + return level, err +} + // checkConfigPerms checks for too open permissions // printing the fix on stdout and returning an error func checkConfigPerms(filename string) error { diff --git a/config/compose.go b/config/compose.go index 14a3087b..70533453 100644 --- a/config/compose.go +++ b/config/compose.go @@ -1,7 +1,6 @@ package config import ( - "fmt" "regexp" "git.sr.ht/~rjarry/aerc/log" @@ -10,54 +9,29 @@ import ( type ComposeConfig struct { Editor string `ini:"editor"` - HeaderLayout [][]string `ini:"-"` + HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"To|From,Subject"` AddressBookCmd string `ini:"address-book-cmd"` - ReplyToSelf bool `ini:"reply-to-self"` - NoAttachmentWarning *regexp.Regexp `ini:"-"` + ReplyToSelf bool `ini:"reply-to-self" default:"true"` + NoAttachmentWarning *regexp.Regexp `ini:"no-attachment-warning" parse:"ParseNoAttachmentWarning"` FilePickerCmd string `ini:"file-picker-cmd"` FormatFlowed bool `ini:"format-flowed"` } -func defaultComposeConfig() *ComposeConfig { - return &ComposeConfig{ - HeaderLayout: [][]string{ - {"To", "From"}, - {"Subject"}, - }, - ReplyToSelf: true, - } -} - -var Compose = defaultComposeConfig() +var Compose = new(ComposeConfig) func parseCompose(file *ini.File) error { - compose, err := file.GetSection("compose") - if err != nil { - goto end - } - - if err := compose.MapTo(&Compose); err != nil { + if err := MapToStruct(file.Section("compose"), Compose, true); err != nil { return err } - for key, val := range compose.KeysHash() { - if key == "header-layout" { - Compose.HeaderLayout = parseLayout(val) - } - - if key == "no-attachment-warning" && len(val) > 0 { - re, err := regexp.Compile("(?im)" + val) - if err != nil { - return fmt.Errorf( - "Invalid no-attachment-warning '%s': %w", - val, err, - ) - } - - Compose.NoAttachmentWarning = re - } - } - -end: log.Debugf("aerc.conf: [compose] %#v", Compose) return nil } + +func (c *ComposeConfig) ParseLayout(sec *ini.Section, key *ini.Key) ([][]string, error) { + layout := parseLayout(key.String()) + return layout, nil +} + +func (c *ComposeConfig) ParseNoAttachmentWarning(sec *ini.Section, key *ini.Key) (*regexp.Regexp, error) { + return regexp.Compile(`(?im)` + key.String()) +} diff --git a/config/config.go b/config/config.go index 6bf767af..de8cd0d0 100644 --- a/config/config.go +++ b/config/config.go @@ -7,28 +7,12 @@ import ( "os" "path" "strings" - "unicode" "github.com/go-ini/ini" "github.com/kyoh86/xdg" "github.com/mitchellh/go-homedir" ) -// 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) -} - // Set at build time var ( shareDir string @@ -126,7 +110,6 @@ func LoadConfigFromFile(root *string, accts []string) error { if err != nil { return err } - file.NameMapper = mapName if err := parseGeneral(file); err != nil { return err diff --git a/config/general.go b/config/general.go index 2921fdf5..0968976b 100644 --- a/config/general.go +++ b/config/general.go @@ -12,45 +12,20 @@ import ( type GeneralConfig struct { DefaultSavePath string `ini:"default-save-path"` - PgpProvider string `ini:"pgp-provider"` + PgpProvider string `ini:"pgp-provider" default:"auto" parse:"ParsePgpProvider"` UnsafeAccountsConf bool `ini:"unsafe-accounts-conf"` LogFile string `ini:"log-file"` - LogLevel log.LogLevel `ini:"-"` + LogLevel log.LogLevel `ini:"log-level" default:"info" parse:"ParseLogLevel"` } -func defaultGeneralConfig() *GeneralConfig { - return &GeneralConfig{ - PgpProvider: "auto", - UnsafeAccountsConf: false, - LogLevel: log.INFO, - } -} - -var General = defaultGeneralConfig() +var General = new(GeneralConfig) func parseGeneral(file *ini.File) error { - var level *ini.Key var logFile *os.File - gen, err := file.GetSection("general") - if err != nil { - goto end - } - if err := gen.MapTo(&General); err != nil { + if err := MapToStruct(file.Section("general"), General, true); err != nil { return err } - level, err = gen.GetKey("log-level") - if err == nil { - l, err := log.ParseLevel(level.String()) - if err != nil { - return err - } - General.LogLevel = l - } - if err := General.validatePgpProvider(); err != nil { - return err - } -end: if !isatty.IsTerminal(os.Stdout.Fd()) { logFile = os.Stdout // redirected to file, force TRACE level @@ -71,11 +46,14 @@ end: return nil } -func (gen *GeneralConfig) validatePgpProvider() error { - switch gen.PgpProvider { +func (gen *GeneralConfig) ParseLogLevel(sec *ini.Section, key *ini.Key) (log.LogLevel, error) { + return log.ParseLevel(key.String()) +} + +func (gen *GeneralConfig) ParsePgpProvider(sec *ini.Section, key *ini.Key) (string, error) { + switch key.String() { case "gpg", "internal", "auto": - return nil - default: - return fmt.Errorf("pgp-provider must be either auto, gpg or internal") + return key.String(), nil } + return "", fmt.Errorf("must be either auto, gpg or internal") } diff --git a/config/parse.go b/config/parse.go new file mode 100644 index 00000000..d836c760 --- /dev/null +++ b/config/parse.go @@ -0,0 +1,233 @@ +package config + +import ( + "errors" + "fmt" + "reflect" + "regexp" + + "git.sr.ht/~rjarry/aerc/lib/templates" + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" +) + +func MapToStruct(s *ini.Section, v interface{}, useDefaults bool) error { + typ := reflect.TypeOf(v) + val := reflect.ValueOf(v) + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + val = val.Elem() + } else { + panic("MapToStruct requires a pointer") + } + if typ.Kind() != reflect.Struct { + panic("MapToStruct requires a pointer to a struct") + } + + for i := 0; i < typ.NumField(); i++ { + fieldVal := val.Field(i) + fieldType := typ.Field(i) + + name := fieldType.Tag.Get("ini") + if name == "" || name == "-" { + continue + } + key, err := s.GetKey(name) + if err != nil { + defValue, found := fieldType.Tag.Lookup("default") + if useDefaults && found { + key, _ = s.NewKey(name, defValue) + } else { + continue + } + } + err = setField(s, key, reflect.ValueOf(v), fieldVal, fieldType) + if err != nil { + return fmt.Errorf("[%s].%s: %w", s.Name(), name, err) + } + } + return nil +} + +func setField( + s *ini.Section, key *ini.Key, struc reflect.Value, + fieldVal reflect.Value, fieldType reflect.StructField, +) error { + var methodValue reflect.Value + method := getParseMethod(s, key, struc, fieldType) + if method.IsValid() { + in := []reflect.Value{reflect.ValueOf(s), reflect.ValueOf(key)} + out := method.Call(in) + err, _ := out[1].Interface().(error) + if err != nil { + return err + } + methodValue = out[0] + } + + ft := fieldType.Type + + switch ft.Kind() { + case reflect.String: + if method.IsValid() { + fieldVal.SetString(methodValue.String()) + } else { + fieldVal.SetString(key.String()) + } + case reflect.Bool: + if method.IsValid() { + fieldVal.SetBool(methodValue.Bool()) + } else { + boolVal, err := key.Bool() + if err != nil { + return err + } + fieldVal.SetBool(boolVal) + } + case reflect.Int32: + // impossible to differentiate rune from int32, they are aliases + // this is an ugly hack but there is no alternative... + if fieldType.Tag.Get("type") == "rune" { + if method.IsValid() { + fieldVal.Set(methodValue) + } else { + runes := []rune(key.String()) + if len(runes) != 1 { + return errors.New("value must be 1 character long") + } + fieldVal.Set(reflect.ValueOf(runes[0])) + } + return nil + } + fallthrough + case reflect.Int64: + // ParseDuration will not return err for `0`, so check the type name + if ft.PkgPath() == "time" && ft.Name() == "Duration" { + durationVal, err := key.Duration() + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(durationVal)) + return nil + } + fallthrough + case reflect.Int, reflect.Int8, reflect.Int16: + if method.IsValid() { + fieldVal.SetInt(methodValue.Int()) + } else { + intVal, err := key.Int64() + if err != nil { + return err + } + fieldVal.SetInt(intVal) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if method.IsValid() { + fieldVal.SetUint(methodValue.Uint()) + } else { + uintVal, err := key.Uint64() + if err != nil { + return err + } + fieldVal.SetUint(uintVal) + } + case reflect.Float32, reflect.Float64: + if method.IsValid() { + fieldVal.SetFloat(methodValue.Float()) + } else { + floatVal, err := key.Float64() + if err != nil { + return err + } + fieldVal.SetFloat(floatVal) + } + case reflect.Slice, reflect.Array: + switch { + case method.IsValid(): + fieldVal.Set(methodValue) + case ft.Elem().Kind() == reflect.Ptr && + typePath(ft.Elem().Elem()) == "net/mail.Address": + addrs, err := mail.ParseAddressList(key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(addrs)) + case ft.Elem().Kind() == reflect.String: + delim := fieldType.Tag.Get("delim") + fieldVal.Set(reflect.ValueOf(key.Strings(delim))) + default: + panic(fmt.Sprintf("unsupported type []%s", typePath(ft.Elem()))) + } + case reflect.Struct: + if method.IsValid() { + fieldVal.Set(methodValue) + } else { + panic(fmt.Sprintf("unsupported type %s", typePath(ft))) + } + case reflect.Ptr: + if method.IsValid() { + fieldVal.Set(methodValue) + } else { + switch typePath(ft.Elem()) { + case "net/mail.Address": + addr, err := mail.ParseAddress(key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(addr)) + case "regexp.Regexp": + r, err := regexp.Compile(key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(r)) + case "text/template.Template": + t, err := templates.ParseTemplate(key.String(), key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(t)) + default: + panic(fmt.Sprintf("unsupported type %s", typePath(ft))) + } + } + default: + panic(fmt.Sprintf("unsupported type %s", typePath(ft))) + } + return nil +} + +func getParseMethod( + section *ini.Section, key *ini.Key, + struc reflect.Value, typ reflect.StructField, +) reflect.Value { + methodName, found := typ.Tag.Lookup("parse") + if !found { + return reflect.Value{} + } + method := struc.MethodByName(methodName) + if !method.IsValid() { + panic(fmt.Sprintf("(*%s).%s: method not found", + struc, methodName)) + } + + if method.Type().NumIn() != 2 || + method.Type().In(0) != reflect.TypeOf(section) || + method.Type().In(1) != reflect.TypeOf(key) || + method.Type().NumOut() != 2 { + panic(fmt.Sprintf("(*%s).%s: invalid signature, expected %s", + struc.Elem().Type().Name(), methodName, + "func(*ini.Section, *ini.Key) (any, error)")) + } + + return method +} + +func typePath(t reflect.Type) string { + var prefix string + if t.Kind() == reflect.Ptr { + t = t.Elem() + prefix = "*" + } + return fmt.Sprintf("%s%s.%s", prefix, t.PkgPath(), t.Name()) +} diff --git a/config/statusline.go b/config/statusline.go index 7cc2140c..d09b8fa3 100644 --- a/config/statusline.go +++ b/config/statusline.go @@ -10,63 +10,22 @@ import ( ) type StatuslineConfig struct { - StatusColumns []*ColumnDef `ini:"-"` - ColumnSeparator string `ini:"column-separator"` - Separator string `ini:"separator"` - DisplayMode string `ini:"display-mode"` - // deprecated - RenderFormat string `ini:"render-format"` + StatusColumns []*ColumnDef `ini:"status-columns" parse:"ParseColumns" default:"left<*,center>=,right>*"` + ColumnSeparator string `ini:"column-separator" default:" "` + Separator string `ini:"separator" default:" | "` + DisplayMode string `ini:"display-mode" default:"text"` } -func defaultStatuslineConfig() *StatuslineConfig { - left, _ := templates.ParseTemplate("column-left", `[{{.Account}}] {{.StatusInfo}}`) - center, _ := templates.ParseTemplate("column-center", `{{.PendingKeys}}`) - right, _ := templates.ParseTemplate("column-right", `{{.TrayInfo}}`) - return &StatuslineConfig{ - StatusColumns: []*ColumnDef{ - { - Name: "left", - Template: left, - Flags: ALIGN_LEFT | WIDTH_AUTO, - }, - { - Name: "center", - Template: center, - Flags: ALIGN_CENTER | WIDTH_FIT, - }, - { - Name: "right", - Template: right, - Flags: ALIGN_RIGHT | WIDTH_AUTO, - }, - }, - ColumnSeparator: " ", - Separator: " | ", - DisplayMode: "text", - // deprecated - RenderFormat: "", - } -} - -var Statusline = defaultStatuslineConfig() +var Statusline = new(StatuslineConfig) func parseStatusline(file *ini.File) error { - statusline, err := file.GetSection("statusline") - if err != nil { - goto out - } - if err := statusline.MapTo(&Statusline); err != nil { + statusline := file.Section("statusline") + if err := MapToStruct(statusline, Statusline, true); err != nil { return err } - if key, err := statusline.GetKey("status-columns"); err == nil { - columns, err := ParseColumnDefs(key, statusline) - if err != nil { - return err - } - Statusline.StatusColumns = columns - } else if Statusline.RenderFormat != "" { - columns, err := convertRenderFormat() + if key, err := statusline.GetKey("render-format"); err == nil { + columns, err := convertRenderFormat(key.String()) if err != nil { return err } @@ -92,20 +51,32 @@ index-format will be removed in aerc 0.17. }) } -out: log.Debugf("aerc.conf: [statusline] %#v", Statusline) return nil } +func (s *StatuslineConfig) ParseColumns(sec *ini.Section, key *ini.Key) ([]*ColumnDef, error) { + if !sec.HasKey("column-left") { + _, _ = sec.NewKey("column-left", "[{{.Account}}] {{.StatusInfo}}") + } + if !sec.HasKey("column-center") { + _, _ = sec.NewKey("column-center", "{{.PendingKeys}}") + } + if !sec.HasKey("column-right") { + _, _ = sec.NewKey("column-right", "{{.TrayInfo}}") + } + return ParseColumnDefs(key, sec) +} + var ( renderFmtRe = regexp.MustCompile(`%(-?\d+)?(\.\d+)?[acdmSTp]`) statuslineMute = false ) -func convertRenderFormat() ([]*ColumnDef, error) { +func convertRenderFormat(renderFormat string) ([]*ColumnDef, error) { var columns []*ColumnDef - tokens := strings.Split(Statusline.RenderFormat, "%>") + tokens := strings.Split(renderFormat, "%>") left := renderFmtRe.ReplaceAllStringFunc( tokens[0], renderVerbToTemplate) diff --git a/config/templates.go b/config/templates.go index f618d365..d3e7cfb0 100644 --- a/config/templates.go +++ b/config/templates.go @@ -2,7 +2,6 @@ package config import ( "path" - "strings" "time" "git.sr.ht/~rjarry/aerc/lib/templates" @@ -13,31 +12,16 @@ import ( type TemplateConfig struct { TemplateDirs []string `ini:"template-dirs" delim:":"` - NewMessage string `ini:"new-message"` - QuotedReply string `ini:"quoted-reply"` - Forwards string `ini:"forwards"` + NewMessage string `ini:"new-message" default:"new_message"` + QuotedReply string `ini:"quoted-reply" default:"quoted_reply"` + Forwards string `ini:"forwards" default:"forward_as_body"` } -func defaultTemplatesConfig() *TemplateConfig { - return &TemplateConfig{ - TemplateDirs: []string{}, - NewMessage: "new_message", - QuotedReply: "quoted_reply", - Forwards: "forward_as_body", - } -} - -var Templates = defaultTemplatesConfig() +var Templates = new(TemplateConfig) func parseTemplates(file *ini.File) error { - if templatesSec, err := file.GetSection("templates"); err == nil { - if err := templatesSec.MapTo(&Templates); err != nil { - return err - } - templateDirs := templatesSec.Key("template-dirs").String() - if templateDirs != "" { - Templates.TemplateDirs = strings.Split(templateDirs, ":") - } + if err := MapToStruct(file.Section("templates"), Templates, true); err != nil { + return err } // append default paths to template-dirs diff --git a/config/triggers.go b/config/triggers.go index c0e70d40..82750c2d 100644 --- a/config/triggers.go +++ b/config/triggers.go @@ -9,43 +9,45 @@ import ( ) type TriggersConfig struct { - NewEmail []string `ini:"-"` + NewEmail []string `ini:"new-email" parse:"ParseNewEmail"` } -var Triggers = &TriggersConfig{} +var Triggers = new(TriggersConfig) func parseTriggers(file *ini.File) error { - var cmd string - triggers, err := file.GetSection("triggers") + if err := MapToStruct(file.Section("triggers"), Triggers, true); err != nil { + return err + } + log.Debugf("aerc.conf: [triggers] %#v", Triggers) + return nil +} + +func (t *TriggersConfig) ParseNewEmail(_ *ini.Section, key *ini.Key) ([]string, error) { + cmd := indexFmtRegexp.ReplaceAllStringFunc( + key.String(), + func(s string) string { + runes := []rune(s) + t, _ := indexVerbToTemplate(runes[len(runes)-1]) + return t + }, + ) + args, err := shlex.Split(cmd) if err != nil { - goto out + return nil, err } - if key := triggers.Key("new-email"); key != nil { - cmd = indexFmtRegexp.ReplaceAllStringFunc( - key.String(), - func(s string) string { - runes := []rune(s) - t, _ := indexVerbToTemplate(runes[len(runes)-1]) - return t - }, - ) - Triggers.NewEmail, err = shlex.Split(cmd) - if err != nil { - return err - } - if cmd != key.String() { - log.Warnf("%s %s", - "The new-email trigger now uses templates instead of %-based placeholders.", - "Backward compatibility will be removed in aerc 0.17.") - Warnings = append(Warnings, Warning{ - Title: "FORMAT CHANGED: [triggers].new-email", - Body: ` + if cmd != key.String() { + log.Warnf("%s %s", + "The new-email trigger now uses templates instead of %-based placeholders.", + "Backward compatibility will be removed in aerc 0.17.") + Warnings = append(Warnings, Warning{ + Title: "FORMAT CHANGED: [triggers].new-email", + Body: ` The new-email trigger now uses templates instead of %-based placeholders. Your configuration in this instance was automatically converted to: [triggers] -new-email = ` + format.ShellQuote(Triggers.NewEmail) + ` +new-email = ` + format.ShellQuote(args) + ` Your configuration file was not changed. To make this change permanent and to dismiss this warning on launch, replace the above line into aerc.conf. See @@ -53,10 +55,7 @@ aerc-config(5) for more details. The automatic conversion of new-email will be removed in aerc 0.17. `, - }) - } + }) } -out: - log.Debugf("aerc.conf: [triggers] %#v", Triggers) - return nil + return args, nil } diff --git a/config/ui.go b/config/ui.go index 64469643..f52cf7f8 100644 --- a/config/ui.go +++ b/config/ui.go @@ -17,17 +17,14 @@ import ( ) type UIConfig struct { - IndexColumns []*ColumnDef `ini:"-"` - ColumnSeparator string `ini:"column-separator"` - // deprecated - IndexFormat string `ini:"index-format"` + IndexColumns []*ColumnDef `ini:"index-columns" parse:"ParseIndexColumns" default:"date<20,name<17,flags>4,subject<*"` + ColumnSeparator string `ini:"column-separator" default:" "` - DirListFormat string `ini:"dirlist-format"` // deprecated - DirListLeft *template.Template `ini:"-"` - DirListRight *template.Template `ini:"-"` + DirListLeft *template.Template `ini:"dirlist-left" default:"{{.Folder}}"` + DirListRight *template.Template `ini:"dirlist-right" default:"{{if .Unread}}{{humanReadable .Unread}}/{{end}}{{if .Exists}}{{humanReadable .Exists}}{{end}}"` - AutoMarkRead bool `ini:"auto-mark-read"` - TimestampFormat string `ini:"timestamp-format"` + AutoMarkRead bool `ini:"auto-mark-read" default:"true"` + TimestampFormat string `ini:"timestamp-format" default:"2006-01-02 03:04 PM"` ThisDayTimeFormat string `ini:"this-day-time-format"` ThisWeekTimeFormat string `ini:"this-week-time-format"` ThisYearTimeFormat string `ini:"this-year-time-format"` @@ -35,49 +32,48 @@ type UIConfig struct { MessageViewThisDayTimeFormat string `ini:"message-view-this-day-time-format"` MessageViewThisWeekTimeFormat string `ini:"message-view-this-week-time-format"` MessageViewThisYearTimeFormat string `ini:"message-view-this-year-time-format"` - 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"` + PinnedTabMarker string "ini:\"pinned-tab-marker\" default:\"`\"" + SidebarWidth int `ini:"sidebar-width" default:"20"` + EmptyMessage string `ini:"empty-message" default:"(no messages)"` + EmptyDirlist string `ini:"empty-dirlist" default:"(no folders)"` MouseEnabled bool `ini:"mouse-enabled"` ThreadingEnabled bool `ini:"threading-enabled"` ForceClientThreads bool `ini:"force-client-threads"` - ClientThreadsDelay time.Duration `ini:"client-threads-delay"` + ClientThreadsDelay time.Duration `ini:"client-threads-delay" default:"50ms"` FuzzyComplete bool `ini:"fuzzy-complete"` - NewMessageBell bool `ini:"new-message-bell"` - Spinner string `ini:"spinner"` - SpinnerDelimiter string `ini:"spinner-delimiter"` - SpinnerInterval time.Duration `ini:"spinner-interval"` + NewMessageBell bool `ini:"new-message-bell" default:"true"` + Spinner string `ini:"spinner" default:"[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] "` + SpinnerDelimiter string `ini:"spinner-delimiter" default:","` + SpinnerInterval time.Duration `ini:"spinner-interval" default:"200ms"` IconUnencrypted string `ini:"icon-unencrypted"` - IconEncrypted string `ini:"icon-encrypted"` - IconSigned string `ini:"icon-signed"` + IconEncrypted string `ini:"icon-encrypted" default:"[e]"` + IconSigned string `ini:"icon-signed" default:"[s]"` IconSignedEncrypted string `ini:"icon-signed-encrypted"` - IconUnknown string `ini:"icon-unknown"` - IconInvalid string `ini:"icon-invalid"` - IconAttachment string `ini:"icon-attachment"` - DirListDelay time.Duration `ini:"dirlist-delay"` + IconUnknown string `ini:"icon-unknown" default:"[s?]"` + IconInvalid string `ini:"icon-invalid" default:"[s!]"` + IconAttachment string `ini:"icon-attachment" default:"a"` + DirListDelay time.Duration `ini:"dirlist-delay" default:"200ms"` DirListTree bool `ini:"dirlist-tree"` DirListCollapse int `ini:"dirlist-collapse"` - Sort []string `delim:" "` + Sort []string `ini:"sort" delim:" "` NextMessageOnDelete bool `ini:"next-message-on-delete"` - CompletionDelay time.Duration `ini:"completion-delay"` - CompletionMinChars int `ini:"completion-min-chars"` - CompletionPopovers bool `ini:"completion-popovers"` + CompletionDelay time.Duration `ini:"completion-delay" default:"250ms"` + CompletionMinChars int `ini:"completion-min-chars" default:"1"` + CompletionPopovers bool `ini:"completion-popovers" default:"true"` StyleSetDirs []string `ini:"stylesets-dirs" delim:":"` - StyleSetName string `ini:"styleset-name"` + StyleSetName string `ini:"styleset-name" default:"default"` style StyleSet // customize border appearance - BorderCharVertical rune `ini:"-"` - BorderCharHorizontal rune `ini:"-"` + BorderCharVertical rune `ini:"border-char-vertical" default:" " type:"rune"` + BorderCharHorizontal rune `ini:"border-char-horizontal" default:" " type:"rune"` ReverseOrder bool `ini:"reverse-msglist-order"` ReverseThreadOrder bool `ini:"reverse-thread-order"` SortThreadSiblings bool `ini:"sort-thread-siblings"` // Tab Templates - TabTitleAccount *template.Template `ini:"-"` - TabTitleComposer *template.Template `ini:"-"` + TabTitleAccount *template.Template `ini:"tab-title-account" default:"{{.Account}}"` + TabTitleComposer *template.Template `ini:"tab-title-composer" default:"{{.Subject}}"` // private contextualUis []*UiConfigContext @@ -106,95 +102,14 @@ type uiContextKey struct { const unreadExists string = `{{if .Unread}}{{humanReadable .Unread}}/{{end}}{{if .Exists}}{{humanReadable .Exists}}{{end}}` -func defaultUiConfig() *UIConfig { - date, _ := templates.ParseTemplate("column-date", "{{.DateAutoFormat .Date.Local}}") - name, _ := templates.ParseTemplate("column-name", "{{index (.From | names) 0}}") - flags, _ := templates.ParseTemplate("column-flags", `{{.Flags | join ""}}`) - subject, _ := templates.ParseTemplate("column-subject", "{{.Subject}}") - left, _ := templates.ParseTemplate("folder", "{{.Folder}}") - right, _ := templates.ParseTemplate("ue", unreadExists) - tabTitleAccount, _ := templates.ParseTemplate("tab-title-account", "{{.Account}}") - tabTitleComposer, _ := templates.ParseTemplate("tab-title-composer", "{{.Subject}}") - return &UIConfig{ - IndexFormat: "", // deprecated - DirListFormat: "", // deprecated - IndexColumns: []*ColumnDef{ - { - Name: "date", - Width: 20, - Flags: ALIGN_LEFT | WIDTH_EXACT, - Template: date, - }, - { - Name: "name", - Width: 17, - Flags: ALIGN_LEFT | WIDTH_EXACT, - Template: name, - }, - { - Name: "flags", - Width: 4, - Flags: ALIGN_RIGHT | WIDTH_EXACT, - Template: flags, - }, - { - Name: "subject", - Flags: ALIGN_LEFT | WIDTH_AUTO, - Template: subject, - }, - }, - DirListLeft: left, - DirListRight: right, - ColumnSeparator: " ", - AutoMarkRead: true, - TimestampFormat: "2006-01-02 03:04 PM", - ThisDayTimeFormat: "", - ThisWeekTimeFormat: "", - ThisYearTimeFormat: "", - PinnedTabMarker: "`", - SidebarWidth: 20, - PreviewHeight: 12, - EmptyMessage: "(no messages)", - EmptyDirlist: "(no folders)", - MouseEnabled: false, - ClientThreadsDelay: 50 * time.Millisecond, - NewMessageBell: true, - TabTitleAccount: tabTitleAccount, - TabTitleComposer: tabTitleComposer, - FuzzyComplete: false, - Spinner: "[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] ", - SpinnerDelimiter: ",", - SpinnerInterval: 200 * time.Millisecond, - IconUnencrypted: "", - IconSigned: "[s]", - IconEncrypted: "[e]", - IconSignedEncrypted: "", - IconUnknown: "[s?]", - IconInvalid: "[s!]", - IconAttachment: "a", - DirListDelay: 200 * time.Millisecond, - NextMessageOnDelete: true, - CompletionDelay: 250 * time.Millisecond, - CompletionMinChars: 1, - CompletionPopovers: true, - StyleSetDirs: []string{}, - StyleSetName: "default", - // border defaults - BorderCharVertical: ' ', - BorderCharHorizontal: ' ', - // private - contextualCache: make(map[uiContextKey]*UIConfig), - contextualCounts: make(map[uiContextType]int), - } +var Ui = &UIConfig{ + contextualCounts: make(map[uiContextType]int), + contextualCache: make(map[uiContextKey]*UIConfig), } -var Ui = defaultUiConfig() - func parseUi(file *ini.File) error { - if ui, err := file.GetSection("ui"); err == nil { - if err := Ui.parse(ui); err != nil { - return err - } + if err := Ui.parse(file.Section("ui")); err != nil { + return err } for _, sectionName := range file.SectionStrings() { @@ -283,64 +198,16 @@ func parseUi(file *ini.File) error { } func (config *UIConfig) parse(section *ini.Section) error { - if err := section.MapTo(config); err != nil { + if err := MapToStruct(section, config, section.Name() == "ui"); err != nil { return err } - if key, err := section.GetKey("border-char-vertical"); err == nil { - chars := []rune(key.String()) - if len(chars) != 1 { - return fmt.Errorf("%v must be one and only one character", key) - } - config.BorderCharVertical = chars[0] - } - if key, err := section.GetKey("border-char-horizontal"); err == nil { - chars := []rune(key.String()) - if len(chars) != 1 { - return fmt.Errorf("%v must be one and only one character", key) - } - config.BorderCharHorizontal = chars[0] - } - - // 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. - if key, err := section.GetKey("dirlist-delay"); err == nil { - dur, err := key.Duration() - if err != nil { - return err - } - config.DirListDelay = dur - } - if key, err := section.GetKey("completion-delay"); err == nil { - dur, err := key.Duration() - if err != nil { - return err - } - config.CompletionDelay = dur - } - if config.MessageViewTimestampFormat == "" { config.MessageViewTimestampFormat = config.TimestampFormat } - if config.MessageViewThisDayTimeFormat == "" { - config.MessageViewThisDayTimeFormat = config.TimestampFormat - } - if config.MessageViewThisWeekTimeFormat == "" { - config.MessageViewThisWeekTimeFormat = config.TimestampFormat - } - if config.MessageViewThisDayTimeFormat == "" { - config.MessageViewThisDayTimeFormat = config.TimestampFormat - } - if key, err := section.GetKey("index-columns"); err == nil { - columns, err := ParseColumnDefs(key, section) - if err != nil { - return err - } - config.IndexColumns = columns - } else if config.IndexFormat != "" { - columns, err := convertIndexFormat(config.IndexFormat) + if key, err := section.GetKey("index-format"); err == nil { + columns, err := convertIndexFormat(key.String()) if err != nil { return err } @@ -366,24 +233,8 @@ index-format will be removed in aerc 0.17. } Warnings = append(Warnings, w) } - left, _ := section.GetKey("dirlist-left") - if left != nil { - t, err := templates.ParseTemplate(left.String(), left.String()) - if err != nil { - return err - } - config.DirListLeft = t - } - right, _ := section.GetKey("dirlist-right") - if right != nil { - t, err := templates.ParseTemplate(right.String(), right.String()) - if err != nil { - return err - } - config.DirListRight = t - } - if left == nil && right == nil && config.DirListFormat != "" { - left, right := convertDirlistFormat(config.DirListFormat) + if key, err := section.GetKey("dirlist-format"); err == nil { + left, right := convertDirlistFormat(key.String()) l, err := templates.ParseTemplate(left, left) if err != nil { return err @@ -418,26 +269,26 @@ dirlist-format will be removed in aerc 0.17. } Warnings = append(Warnings, w) } - if key, err := section.GetKey("tab-title-account"); err == nil { - val := key.Value() - tmpl, err := templates.ParseTemplate("tab-title-account", val) - if err != nil { - return err - } - config.TabTitleAccount = tmpl - } - if key, err := section.GetKey("tab-title-composer"); err == nil { - val := key.Value() - tmpl, err := templates.ParseTemplate("tab-title-composer", val) - if err != nil { - return err - } - config.TabTitleComposer = tmpl - } return nil } +func (*UIConfig) ParseIndexColumns(section *ini.Section, key *ini.Key) ([]*ColumnDef, error) { + if !section.HasKey("column-date") { + _, _ = section.NewKey("column-date", `{{.DateAutoFormat .Date.Local}}`) + } + if !section.HasKey("column-name") { + _, _ = section.NewKey("column-name", `{{index (.From | names) 0}}`) + } + if !section.HasKey("column-flags") { + _, _ = section.NewKey("column-flags", `{{.Flags | join ""}}`) + } + if !section.HasKey("column-subject") { + _, _ = section.NewKey("column-subject", `{{.Subject}}`) + } + return ParseColumnDefs(key, section) +} + var indexFmtRegexp = regexp.MustCompile(`%(-?\d+)?(\.\d+)?([ACDFRTZadfgilnrstuv])`) func convertIndexFormat(indexFormat string) ([]*ColumnDef, error) { diff --git a/config/viewer.go b/config/viewer.go index c359f43b..091fb4b6 100644 --- a/config/viewer.go +++ b/config/viewer.go @@ -1,56 +1,31 @@ package config import ( - "strings" - "git.sr.ht/~rjarry/aerc/log" "github.com/go-ini/ini" ) type ViewerConfig struct { - Pager string - Alternatives []string + Pager string `ini:"pager" default:"less -R"` + Alternatives []string `ini:"alternatives" default:"text/plain,text/html" delim:","` ShowHeaders bool `ini:"show-headers"` AlwaysShowMime bool `ini:"always-show-mime"` - ParseHttpLinks bool `ini:"parse-http-links"` - HeaderLayout [][]string `ini:"-"` - KeyPassthrough bool `ini:"-"` -} - -func defaultViewerConfig() *ViewerConfig { - return &ViewerConfig{ - Pager: "less -R", - Alternatives: []string{"text/plain", "text/html"}, - ShowHeaders: false, - HeaderLayout: [][]string{ - {"From", "To"}, - {"Cc", "Bcc"}, - {"Date"}, - {"Subject"}, - }, - ParseHttpLinks: true, - } + ParseHttpLinks bool `ini:"parse-http-links" default:"true"` + HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"From|To,Cc|Bcc,Date,Subject"` + KeyPassthrough bool } -var Viewer = defaultViewerConfig() +var Viewer = new(ViewerConfig) func parseViewer(file *ini.File) error { - viewer, err := file.GetSection("viewer") - if err != nil { - goto out - } - if err := viewer.MapTo(&Viewer); err != nil { + if err := MapToStruct(file.Section("viewer"), Viewer, true); err != nil { return err } - for key, val := range viewer.KeysHash() { - switch key { - case "alternatives": - Viewer.Alternatives = strings.Split(val, ",") - case "header-layout": - Viewer.HeaderLayout = parseLayout(val) - } - } -out: log.Debugf("aerc.conf: [viewer] %#v", Viewer) return nil } + +func (v *ViewerConfig) ParseLayout(sec *ini.Section, key *ini.Key) ([][]string, error) { + layout := parseLayout(key.String()) + return layout, nil +} |