Add basic filter implementation
This commit is contained in:
parent
711d22891b
commit
bbdf9df75e
|
@ -19,7 +19,7 @@ func ViewMessage(aerc *widgets.Aerc, args []string) error {
|
||||||
acct := aerc.SelectedAccount()
|
acct := aerc.SelectedAccount()
|
||||||
store := acct.Messages().Store()
|
store := acct.Messages().Store()
|
||||||
msg := acct.Messages().Selected()
|
msg := acct.Messages().Selected()
|
||||||
viewer := widgets.NewMessageViewer(store, msg)
|
viewer := widgets.NewMessageViewer(aerc.Config(), store, msg)
|
||||||
aerc.NewTab(viewer, runewidth.Truncate(
|
aerc.NewTab(viewer, runewidth.Truncate(
|
||||||
msg.Envelope.Subject, 32, "…"))
|
msg.Envelope.Subject, 32, "…"))
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -54,32 +54,12 @@ empty-message=(no messages)
|
||||||
|
|
||||||
[viewer]
|
[viewer]
|
||||||
#
|
#
|
||||||
# We can use different programs to display various kinds of email attachments.
|
# Specifies the pager to use when displaying emails. Note that some filters
|
||||||
# These programs will have the mail piped into them and are expected to output
|
# may add ANSI codes to add color to rendered emails, so you may want to use a
|
||||||
# it ready to display on a terminal (you can include terminal control
|
# pager which supports ANSI codes.
|
||||||
# characters if you like, for colors and such). Emails will be stripped of
|
|
||||||
# non-printable characters before being piped into these commands, and will be
|
|
||||||
# encoded with UTF-8. These commands are invoked with sh and run
|
|
||||||
# non-interactively, and their output is piped into your pager command
|
|
||||||
# (interactively). The following environment variables will be set:
|
|
||||||
#
|
#
|
||||||
# $WIDTH: the width of the terminal window
|
# Default: less -R
|
||||||
# $HEIGHT: the height of the terminal window
|
pager=less -R
|
||||||
# $MIMETYPE: the email's mimetype
|
|
||||||
#
|
|
||||||
# You can use * as a wildcard for any subtype of a given mimetype. When
|
|
||||||
# displaying a text/* message and no command matches, the message will just be
|
|
||||||
# piped directly into your pager (after being stripped of non-printable
|
|
||||||
# characters).
|
|
||||||
|
|
||||||
# Examples:
|
|
||||||
#
|
|
||||||
#text/html=w3m -T text/html -cols $WIDTH -dump -o display_image=false -o display_link_number=true
|
|
||||||
text/*=fold -sw $WIDTH
|
|
||||||
|
|
||||||
#
|
|
||||||
# Default: less -r
|
|
||||||
pager=less -r
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# If an email offers several versions (multipart), you can configure which
|
# If an email offers several versions (multipart), you can configure which
|
||||||
|
@ -89,6 +69,21 @@ pager=less -r
|
||||||
# Default: text/plain,text/html
|
# Default: text/plain,text/html
|
||||||
alternatives=text/plain,text/html
|
alternatives=text/plain,text/html
|
||||||
|
|
||||||
|
[filters]
|
||||||
|
#
|
||||||
|
# Filters allow you to pipe an email body through a shell command to render
|
||||||
|
# certain emails differently, e.g. highlighting them with ANSI escape codes.
|
||||||
|
#
|
||||||
|
# The first filter which matches the email's mimetype will be used, so order
|
||||||
|
# them from most to least specific.
|
||||||
|
#
|
||||||
|
# You can also match on non-mimetypes, by prefixing with the header to match
|
||||||
|
# against (non-case-sensitive) and a colon, e.g. subject:text will match a
|
||||||
|
# subject which contains "text". Use header~:regex to match against a regex.
|
||||||
|
subject~:PATCH=contrib/hldiff.py
|
||||||
|
text/html=w3m -T text/html -cols $(tput cols) -dump -o display_image=false -o display_link_number=true
|
||||||
|
text/*=contrib/plaintext.py
|
||||||
|
|
||||||
[lbinds]
|
[lbinds]
|
||||||
#
|
#
|
||||||
# Binds are of the form <input keys> = <output keys>
|
# Binds are of the form <input keys> = <output keys>
|
||||||
|
|
|
@ -22,6 +22,12 @@ type UIConfig struct {
|
||||||
EmptyMessage string `ini:"empty-message"`
|
EmptyMessage string `ini:"empty-message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
FILTER_MIMETYPE = iota
|
||||||
|
FILTER_HEADER
|
||||||
|
FILTER_HEADER_REGEX
|
||||||
|
)
|
||||||
|
|
||||||
type AccountConfig struct {
|
type AccountConfig struct {
|
||||||
Default string
|
Default string
|
||||||
Name string
|
Name string
|
||||||
|
@ -38,10 +44,23 @@ type BindingConfig struct {
|
||||||
Terminal *KeyBindings
|
Terminal *KeyBindings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FilterConfig struct {
|
||||||
|
FilterType int
|
||||||
|
Filter string
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewerConfig struct {
|
||||||
|
Pager string
|
||||||
|
Alternatives []string
|
||||||
|
}
|
||||||
|
|
||||||
type AercConfig struct {
|
type AercConfig struct {
|
||||||
Bindings BindingConfig
|
Bindings BindingConfig
|
||||||
Ini *ini.File `ini:"-"`
|
Ini *ini.File `ini:"-"`
|
||||||
Accounts []AccountConfig `ini:"-"`
|
Accounts []AccountConfig `ini:"-"`
|
||||||
|
Filters []FilterConfig `ini:"-"`
|
||||||
|
Viewer ViewerConfig `ini:"-"`
|
||||||
Ui UIConfig
|
Ui UIConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,6 +154,34 @@ func LoadConfig(root *string) (*AercConfig, error) {
|
||||||
EmptyMessage: "(no messages)",
|
EmptyMessage: "(no messages)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if filters, err := file.GetSection("filters"); err == nil {
|
||||||
|
// TODO: Parse the filter more finely, e.g. parse the regex
|
||||||
|
for match, cmd := range filters.KeysHash() {
|
||||||
|
filter := FilterConfig{
|
||||||
|
Command: cmd,
|
||||||
|
Filter: match,
|
||||||
|
}
|
||||||
|
if strings.Contains(match, "~:") {
|
||||||
|
filter.FilterType = FILTER_HEADER_REGEX
|
||||||
|
} else if strings.ContainsRune(match, ':') {
|
||||||
|
filter.FilterType = FILTER_HEADER
|
||||||
|
} else {
|
||||||
|
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 nil, err
|
||||||
|
}
|
||||||
|
for key, val := range viewer.KeysHash() {
|
||||||
|
switch key {
|
||||||
|
case "alternatives":
|
||||||
|
config.Viewer.Alternatives = strings.Split(val, ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if ui, err := file.GetSection("ui"); err == nil {
|
if ui, err := file.GetSection("ui"); err == nil {
|
||||||
if err := ui.MapTo(&config.Ui); err != nil {
|
if err := ui.MapTo(&config.Ui); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
3
go.mod
3
go.mod
|
@ -3,9 +3,12 @@ module git.sr.ht/~sircmpwn/aerc2
|
||||||
require (
|
require (
|
||||||
git.sr.ht/~sircmpwn/go-libvterm v0.0.0-20190322002230-17c9f17a421a
|
git.sr.ht/~sircmpwn/go-libvterm v0.0.0-20190322002230-17c9f17a421a
|
||||||
git.sr.ht/~sircmpwn/pty v0.0.0-20190330154901-3a43678975a9
|
git.sr.ht/~sircmpwn/pty v0.0.0-20190330154901-3a43678975a9
|
||||||
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964
|
||||||
github.com/emersion/go-imap v1.0.0-beta.1
|
github.com/emersion/go-imap v1.0.0-beta.1
|
||||||
github.com/emersion/go-imap-idle v0.0.0-20180114101550-2af93776db6b
|
github.com/emersion/go-imap-idle v0.0.0-20180114101550-2af93776db6b
|
||||||
|
github.com/emersion/go-message v0.9.2
|
||||||
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 // indirect
|
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 // indirect
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe // indirect
|
||||||
github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635
|
github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635
|
||||||
github.com/gdamore/tcell v1.0.0
|
github.com/gdamore/tcell v1.0.0
|
||||||
github.com/go-ini/ini v1.42.0
|
github.com/go-ini/ini v1.42.0
|
||||||
|
|
6
go.sum
6
go.sum
|
@ -22,14 +22,20 @@ git.sr.ht/~sircmpwn/go-libvterm v0.0.0-20190322002230-17c9f17a421a h1:ktjo0NVokh
|
||||||
git.sr.ht/~sircmpwn/go-libvterm v0.0.0-20190322002230-17c9f17a421a/go.mod h1:hT88+cTemwwESbMptwC7O33qrJfQX0SgRWbXlndUS2c=
|
git.sr.ht/~sircmpwn/go-libvterm v0.0.0-20190322002230-17c9f17a421a/go.mod h1:hT88+cTemwwESbMptwC7O33qrJfQX0SgRWbXlndUS2c=
|
||||||
git.sr.ht/~sircmpwn/pty v0.0.0-20190330154901-3a43678975a9 h1:WWPN5lf6KzXp3xWRrPQZ4MLR3yrFEI4Ysz7HSQ1G/yo=
|
git.sr.ht/~sircmpwn/pty v0.0.0-20190330154901-3a43678975a9 h1:WWPN5lf6KzXp3xWRrPQZ4MLR3yrFEI4Ysz7HSQ1G/yo=
|
||||||
git.sr.ht/~sircmpwn/pty v0.0.0-20190330154901-3a43678975a9/go.mod h1:8Jmcax8M9nYoEwBhVBhv2ixLRCoUqlbQPE95VpPu43I=
|
git.sr.ht/~sircmpwn/pty v0.0.0-20190330154901-3a43678975a9/go.mod h1:8Jmcax8M9nYoEwBhVBhv2ixLRCoUqlbQPE95VpPu43I=
|
||||||
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
||||||
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/emersion/go-imap v1.0.0-beta.1 h1:bTCaVlUnb5mKoW9lEukusxguSYYZPer+q0g5t+vw5X0=
|
github.com/emersion/go-imap v1.0.0-beta.1 h1:bTCaVlUnb5mKoW9lEukusxguSYYZPer+q0g5t+vw5X0=
|
||||||
github.com/emersion/go-imap v1.0.0-beta.1/go.mod h1:oydmHwiyv92ZOiNfQY9BDax5heePWN8P2+W1B2T6qjc=
|
github.com/emersion/go-imap v1.0.0-beta.1/go.mod h1:oydmHwiyv92ZOiNfQY9BDax5heePWN8P2+W1B2T6qjc=
|
||||||
github.com/emersion/go-imap-idle v0.0.0-20180114101550-2af93776db6b h1:q4qkNe/W10qFGD3RWd4meQTkD0+Zrz0L4ekMvlptg60=
|
github.com/emersion/go-imap-idle v0.0.0-20180114101550-2af93776db6b h1:q4qkNe/W10qFGD3RWd4meQTkD0+Zrz0L4ekMvlptg60=
|
||||||
github.com/emersion/go-imap-idle v0.0.0-20180114101550-2af93776db6b/go.mod h1:o14zPKCmEH5WC1vU5SdPoZGgNvQx7zzKSnxPQlobo78=
|
github.com/emersion/go-imap-idle v0.0.0-20180114101550-2af93776db6b/go.mod h1:o14zPKCmEH5WC1vU5SdPoZGgNvQx7zzKSnxPQlobo78=
|
||||||
|
github.com/emersion/go-message v0.9.2 h1:rJmtGZO1Z71PJDQXbC31EwzlJCsA/8kya6GnebSGp6I=
|
||||||
|
github.com/emersion/go-message v0.9.2/go.mod h1:m3cK90skCWxm5sIMs1sXxly4Tn9Plvcf6eayHZJ1NzM=
|
||||||
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 h1:rDJPbyliyym8ZL/Wt71kdolp6yaD4fLIQz638E6JEt0=
|
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 h1:rDJPbyliyym8ZL/Wt71kdolp6yaD4fLIQz638E6JEt0=
|
||||||
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
|
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635 h1:hheUEMzaOie/wKeIc1WPa7CDVuIO5hqQxjS+dwTQEnI=
|
github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635 h1:hheUEMzaOie/wKeIc1WPa7CDVuIO5hqQxjS+dwTQEnI=
|
||||||
github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635/go.mod h1:yrQYJKKDTrHmbYxI7CYi+/hbdiDT2m4Hj+t0ikCjsrQ=
|
github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635/go.mod h1:yrQYJKKDTrHmbYxI7CYi+/hbdiDT2m4Hj+t0ikCjsrQ=
|
||||||
github.com/gdamore/tcell v1.0.0 h1:oaly4AkxvDT5ffKHV/n4L8iy6FxG2QkAVl0M6cjryuE=
|
github.com/gdamore/tcell v1.0.0 h1:oaly4AkxvDT5ffKHV/n4L8iy6FxG2QkAVl0M6cjryuE=
|
||||||
|
|
|
@ -6,24 +6,30 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/danwakefield/fnmatch"
|
||||||
"github.com/emersion/go-imap"
|
"github.com/emersion/go-imap"
|
||||||
"github.com/emersion/go-message"
|
"github.com/emersion/go-message"
|
||||||
"github.com/emersion/go-message/mail"
|
"github.com/emersion/go-message/mail"
|
||||||
"github.com/gdamore/tcell"
|
"github.com/gdamore/tcell"
|
||||||
|
"github.com/google/shlex"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"git.sr.ht/~sircmpwn/aerc2/config"
|
||||||
"git.sr.ht/~sircmpwn/aerc2/lib"
|
"git.sr.ht/~sircmpwn/aerc2/lib"
|
||||||
"git.sr.ht/~sircmpwn/aerc2/lib/ui"
|
"git.sr.ht/~sircmpwn/aerc2/lib/ui"
|
||||||
"git.sr.ht/~sircmpwn/aerc2/worker/types"
|
"git.sr.ht/~sircmpwn/aerc2/worker/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MessageViewer struct {
|
type MessageViewer struct {
|
||||||
cmd *exec.Cmd
|
conf *config.AercConfig
|
||||||
msg *types.MessageInfo
|
filter *exec.Cmd
|
||||||
source io.Reader
|
msg *types.MessageInfo
|
||||||
sink io.WriteCloser
|
pager *exec.Cmd
|
||||||
grid *ui.Grid
|
source io.Reader
|
||||||
term *Terminal
|
pagerin io.WriteCloser
|
||||||
|
sink io.WriteCloser
|
||||||
|
grid *ui.Grid
|
||||||
|
term *Terminal
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatAddresses(addrs []*imap.Address) string {
|
func formatAddresses(addrs []*imap.Address) string {
|
||||||
|
@ -43,7 +49,7 @@ func formatAddresses(addrs []*imap.Address) string {
|
||||||
return val.String()
|
return val.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageViewer(store *lib.MessageStore,
|
func NewMessageViewer(conf *config.AercConfig, store *lib.MessageStore,
|
||||||
msg *types.MessageInfo) *MessageViewer {
|
msg *types.MessageInfo) *MessageViewer {
|
||||||
|
|
||||||
grid := ui.NewGrid().Rows([]ui.GridSpec{
|
grid := ui.NewGrid().Rows([]ui.GridSpec{
|
||||||
|
@ -86,9 +92,40 @@ func NewMessageViewer(store *lib.MessageStore,
|
||||||
{ui.SIZE_EXACT, 20},
|
{ui.SIZE_EXACT, 20},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := exec.Command("less")
|
var (
|
||||||
pipe, _ := cmd.StdinPipe()
|
filter *exec.Cmd
|
||||||
term, _ := NewTerminal(cmd)
|
pager *exec.Cmd
|
||||||
|
pipe io.WriteCloser
|
||||||
|
pagerin io.WriteCloser
|
||||||
|
)
|
||||||
|
cmd, err := shlex.Split(conf.Viewer.Pager)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // TODO: something useful
|
||||||
|
}
|
||||||
|
pager = exec.Command(cmd[0], cmd[1:]...)
|
||||||
|
|
||||||
|
for _, f := range conf.Filters {
|
||||||
|
cmd, err := shlex.Split(f.Command)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // TODO: Something useful
|
||||||
|
}
|
||||||
|
mime := msg.BodyStructure.MIMEType + "/" + msg.BodyStructure.MIMESubType
|
||||||
|
switch f.FilterType {
|
||||||
|
case config.FILTER_MIMETYPE:
|
||||||
|
if fnmatch.Match(f.Filter, mime, 0) {
|
||||||
|
filter = exec.Command(cmd[0], cmd[1:]...)
|
||||||
|
fmt.Printf("Using filter for %s: %s\n", mime, f.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filter != nil {
|
||||||
|
pipe, _ = filter.StdinPipe()
|
||||||
|
pagerin, _ = pager.StdinPipe()
|
||||||
|
} else {
|
||||||
|
pipe, _ = pager.StdinPipe()
|
||||||
|
}
|
||||||
|
|
||||||
|
term, _ := NewTerminal(pager)
|
||||||
// TODO: configure multipart view. I left a spot for it in the grid
|
// TODO: configure multipart view. I left a spot for it in the grid
|
||||||
body.AddChild(term).At(0, 0).Span(1, 2)
|
body.AddChild(term).At(0, 0).Span(1, 2)
|
||||||
|
|
||||||
|
@ -96,11 +133,13 @@ func NewMessageViewer(store *lib.MessageStore,
|
||||||
grid.AddChild(body).At(1, 0)
|
grid.AddChild(body).At(1, 0)
|
||||||
|
|
||||||
viewer := &MessageViewer{
|
viewer := &MessageViewer{
|
||||||
cmd: cmd,
|
filter: filter,
|
||||||
grid: grid,
|
grid: grid,
|
||||||
msg: msg,
|
msg: msg,
|
||||||
sink: pipe,
|
pager: pager,
|
||||||
term: term,
|
pagerin: pagerin,
|
||||||
|
sink: pipe,
|
||||||
|
term: term,
|
||||||
}
|
}
|
||||||
|
|
||||||
store.FetchBodyPart(msg.Uid, 0, func(reader io.Reader) {
|
store.FetchBodyPart(msg.Uid, 0, func(reader io.Reader) {
|
||||||
|
@ -116,12 +155,22 @@ func NewMessageViewer(store *lib.MessageStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mv *MessageViewer) attemptCopy() {
|
func (mv *MessageViewer) attemptCopy() {
|
||||||
if mv.source != nil && mv.cmd.Process != nil {
|
if mv.source != nil && mv.pager.Process != nil {
|
||||||
header := make(message.Header)
|
header := make(message.Header)
|
||||||
header.Set("Content-Transfer-Encoding", mv.msg.BodyStructure.Encoding)
|
header.Set("Content-Transfer-Encoding", mv.msg.BodyStructure.Encoding)
|
||||||
header.SetContentType(
|
header.SetContentType(
|
||||||
mv.msg.BodyStructure.MIMEType, mv.msg.BodyStructure.Params)
|
mv.msg.BodyStructure.MIMEType, mv.msg.BodyStructure.Params)
|
||||||
header.SetContentDescription(mv.msg.BodyStructure.Description)
|
header.SetContentDescription(mv.msg.BodyStructure.Description)
|
||||||
|
if mv.filter != nil {
|
||||||
|
stdout, _ := mv.filter.StdoutPipe()
|
||||||
|
mv.filter.Start()
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(mv.pagerin, stdout)
|
||||||
|
if err != nil {
|
||||||
|
io.WriteString(mv.sink, err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
entity, err := message.New(header, mv.source)
|
entity, err := message.New(header, mv.source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
Loading…
Reference in New Issue