mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-03-02 18:23:33 +01:00
style: allow specifying multiple headers on dynamic styles
Change the `Style` struct to hold a map of header -> pattern mappings instead of a single pair. Must all match for a style to be applied, effectively implementing an AND condition. Technically, this actually changes the matching from O(n) to O(n*m), `n` being the number of styleset entries and `m` the number of (user-specified) header patterns. Realistically, in most cases m == 1 will hold true anyway and more than 2-3 header patterns will not be used in practice much either -- so this is fine. Update man pages accordingly. Mention the ordering-sensitive property of the matching. Add unit tests to ensure it works as expected. Changelog-added: Dynamic message list style can now match on multiple email headers. Signed-off-by: Christoph Heiss <christoph@c8h4.io> Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
committed by
Robin Jarry
parent
a8bac8b75e
commit
65dc87db62
102
config/style.go
102
config/style.go
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -121,6 +122,11 @@ var StyleNames = map[string]StyleObject{
|
||||
"selector_chooser": STYLE_SELECTOR_CHOOSER,
|
||||
}
|
||||
|
||||
type StyleHeaderPattern struct {
|
||||
RawPattern string
|
||||
Re *regexp.Regexp
|
||||
}
|
||||
|
||||
type Style struct {
|
||||
Fg vaxis.Color
|
||||
Bg vaxis.Color
|
||||
@@ -130,9 +136,9 @@ type Style struct {
|
||||
Reverse bool
|
||||
Italic bool
|
||||
Dim bool
|
||||
header string // only for msglist
|
||||
pattern string // only for msglist
|
||||
re *regexp.Regexp // only for msglist
|
||||
// Only for msglist, maps header -> pattern/regexp
|
||||
// All regexps must match in order for the style to be applied
|
||||
headerPatterns map[string]*StyleHeaderPattern
|
||||
}
|
||||
|
||||
func (s Style) Get() vaxis.Style {
|
||||
@@ -182,6 +188,12 @@ func (s *Style) Reset() *Style {
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Style) hasSameHeaderPatterns(other map[string]*StyleHeaderPattern) bool {
|
||||
return maps.EqualFunc(s.headerPatterns, other, func(a, b *StyleHeaderPattern) bool {
|
||||
return a.RawPattern == b.RawPattern
|
||||
})
|
||||
}
|
||||
|
||||
func boolSwitch(val string, cur_val bool) (bool, error) {
|
||||
switch val {
|
||||
case "true":
|
||||
@@ -370,14 +382,23 @@ func (c *StyleConf) getStyle(h *mail.Header) *Style {
|
||||
if h == nil {
|
||||
return &c.base
|
||||
}
|
||||
style := &c.base
|
||||
|
||||
// All dynamic styles must be iterated through, as later ones might be a
|
||||
// narrower match based due to multiple header patterns.
|
||||
for _, s := range c.dynamic {
|
||||
val, _ := h.Text(s.header)
|
||||
if s.re.MatchString(val) {
|
||||
s = c.base.composeWith([]*Style{&s})
|
||||
return &s
|
||||
allMatch := true
|
||||
for header, pattern := range s.headerPatterns {
|
||||
val, _ := h.Text(header)
|
||||
allMatch = allMatch && pattern.Re.MatchString(val)
|
||||
}
|
||||
|
||||
if allMatch {
|
||||
s := c.base.composeWith([]*Style{&s})
|
||||
style = &s
|
||||
}
|
||||
}
|
||||
return &c.base
|
||||
return style
|
||||
}
|
||||
|
||||
func (ss StyleSet) Get(so StyleObject, h *mail.Header) vaxis.Style {
|
||||
@@ -483,18 +504,30 @@ func (ss *StyleSet) ParseStyleSet(file *ini.File) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var styleObjRe = regexp.MustCompile(`^([\w\*\?]+)(?:\.([\w-]+),(.+?))?(\.selected)?\.(\w+)$`)
|
||||
var (
|
||||
styleObjRe = regexp.MustCompile(`^([\w\*\?]+)(?:\.([\w-]+,.+?)+?)?(\.selected)?\.(\w+)$`)
|
||||
styleHeaderPatternsRe = regexp.MustCompile(`([\w-]+),(.+?)\.`)
|
||||
)
|
||||
|
||||
func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error {
|
||||
groups := styleObjRe.FindStringSubmatch(key.Name())
|
||||
if groups == nil {
|
||||
return errors.New("invalid style syntax: " + key.Name())
|
||||
}
|
||||
if (groups[4] == ".selected") != selected {
|
||||
if (groups[3] == ".selected") != selected {
|
||||
return nil
|
||||
}
|
||||
obj, attr := groups[1], groups[5]
|
||||
header, pattern := groups[2], groups[3]
|
||||
obj, attr := groups[1], groups[4]
|
||||
|
||||
// As there can be multiple header patterns, match them separately, one
|
||||
// by one
|
||||
headerMatches := styleHeaderPatternsRe.FindAllStringSubmatch(groups[2]+".", -1)
|
||||
headerPatterns := make(map[string]*StyleHeaderPattern)
|
||||
for _, match := range headerMatches {
|
||||
headerPatterns[match[1]] = &StyleHeaderPattern{
|
||||
RawPattern: match[2],
|
||||
}
|
||||
}
|
||||
|
||||
objRe, err := fnmatchToRegex(obj)
|
||||
if err != nil {
|
||||
@@ -506,12 +539,12 @@ func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error {
|
||||
continue
|
||||
}
|
||||
if !selected {
|
||||
err = ss.objects[so].update(header, pattern, attr, key.Value())
|
||||
err = ss.objects[so].update(headerPatterns, attr, key.Value())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err)
|
||||
}
|
||||
}
|
||||
err = ss.selected[so].update(header, pattern, attr, key.Value())
|
||||
err = ss.selected[so].update(headerPatterns, attr, key.Value())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err)
|
||||
}
|
||||
@@ -523,34 +556,41 @@ func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *StyleConf) update(header, pattern, attr, val string) error {
|
||||
if header == "" || pattern == "" {
|
||||
func (c *StyleConf) update(headerPatterns map[string]*StyleHeaderPattern, attr, val string) error {
|
||||
if len(headerPatterns) == 0 {
|
||||
return (&c.base).Set(attr, val)
|
||||
}
|
||||
|
||||
// Check existing entries and overwrite ones with same header/pattern
|
||||
for i := range c.dynamic {
|
||||
s := &c.dynamic[i]
|
||||
if s.header == header && s.pattern == pattern {
|
||||
if s.hasSameHeaderPatterns(headerPatterns) {
|
||||
return s.Set(attr, val)
|
||||
}
|
||||
}
|
||||
s := Style{
|
||||
header: header,
|
||||
pattern: pattern,
|
||||
}
|
||||
if strings.HasPrefix(pattern, "~") {
|
||||
pattern = pattern[1:]
|
||||
} else {
|
||||
pattern = "^" + regexp.QuoteMeta(pattern) + "$"
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
|
||||
s := Style{}
|
||||
err := (&s).Set(attr, val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = (&s).Set(attr, val)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
for _, p := range headerPatterns {
|
||||
var pattern string
|
||||
if strings.HasPrefix(p.RawPattern, "~") {
|
||||
pattern = p.RawPattern[1:]
|
||||
} else {
|
||||
pattern = "^" + regexp.QuoteMeta(p.RawPattern) + "$"
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Re = re
|
||||
}
|
||||
s.re = re
|
||||
|
||||
s.headerPatterns = headerPatterns
|
||||
c.dynamic = append(c.dynamic, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
89
config/style_test.go
Normal file
89
config/style_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
const multiHeaderStyleset string = `
|
||||
msglist_*.fg = salmon
|
||||
msglist_*.From,~^"Bob Foo".fg = khaki
|
||||
msglist_*.From,~^"Bob Foo".selected.fg = palegreen
|
||||
msglist_*.From,~^"Bob Foo".Subject,~PATCH.fg = coral
|
||||
msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Baz,exact.X-Clacks-Overhead,~Pratchett$.fg = plum
|
||||
msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Clacks-Overhead,~Pratchett$.fg = pink
|
||||
`
|
||||
|
||||
func TestStyleMultiHeaderPattern(t *testing.T) {
|
||||
ini, err := ini.Load([]byte(multiHeaderStyleset))
|
||||
if err != nil {
|
||||
t.Errorf("failed to load styleset: %v", err)
|
||||
}
|
||||
|
||||
ss := NewStyleSet()
|
||||
err = ss.ParseStyleSet(ini)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse styleset: %v", err)
|
||||
}
|
||||
|
||||
t.Run("default color", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Alice Foo", "alice@foo.org"}})
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["salmon"] {
|
||||
t.Errorf("expected:#%v got:#%v", colorNames["salmon"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single header", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "bob@foo.org"}})
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["khaki"] {
|
||||
t.Errorf("expected:#%v got:#%v", colorNames["khaki"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two headers", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
|
||||
h.SetSubject("[PATCH] tests")
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["coral"] {
|
||||
t.Errorf("expected:#%x got:#%x", colorNames["coral"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple headers", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
|
||||
h.SetSubject("[PATCH] tests")
|
||||
h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["pink"] {
|
||||
t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves order-sensitivity", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
|
||||
h.SetSubject("[PATCH] tests")
|
||||
h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
h.SetText("X-Baz", "exact")
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
|
||||
// The "pink" entry comes later, so will overrule the more exact
|
||||
// match with color "plum"
|
||||
if s.Foreground != colorNames["pink"] {
|
||||
t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -315,6 +315,10 @@ syntax is as follows:
|
||||
If _<header_value>_ starts with a tilde character _~_, it will be interpreted as
|
||||
a regular expression.
|
||||
|
||||
_<header>,<header_value>_ can be specified multiple times to narrow down matches
|
||||
to more than one email header value. In that case, all given headers must match
|
||||
for the dynamic style to apply.
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
@@ -323,6 +327,7 @@ msglist\*.X-Sourcehut-Patchset-Update,NEEDS\_REVISION.fg = yellow
|
||||
msglist\*.X-Sourcehut-Patchset-Update,REJECTED.fg = red
|
||||
"msglist_*.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.fg" = #ffffaf
|
||||
"msglist_*.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.selected.fg" = #ffffaf
|
||||
"msglist_*.From,~^Bob.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.selected.fg" = #ffffaf
|
||||
```
|
||||
|
||||
When a dynamic style is matched to an email header, it will be used in priority
|
||||
@@ -336,6 +341,17 @@ msglist_*.Subject,~foobar.fg = red
|
||||
An email with _foobar_ in its subject will be colored in _red_ all the time,
|
||||
since *msglist_\** also applies to *msglist\_marked*.
|
||||
|
||||
When multiple _<header>,<header_value>_ pairs are given, the last style which
|
||||
matches all given patterns will be applied. Provided the following styleset:
|
||||
|
||||
```
|
||||
msglist_*.From,~^Bob.Subject,~foobar.fg = red
|
||||
msglist_*.From,~^Bob.fg = blue
|
||||
```
|
||||
|
||||
An email from _Bob_ with _foobar_ in its subject will be colored in _blue_,
|
||||
since the second style is a full match too.
|
||||
|
||||
# COLORS
|
||||
|
||||
The color values are set using any of the following methods:
|
||||
|
||||
Reference in New Issue
Block a user