aerc/lib/format/format.go
Pinghao Wu b8ef8d2628 index: workaround for wide character printing
This is a workaround for https://todo.sr.ht/~rjarry/aerc/22, by
injecting zero-width spaces in front of wide characters to make up width
calculation in fmt.Sprintf.

With this applied, columns will still be misaligned a little only if
they are left with trailing zero-width spaces when cut. It is better
than a large offset caused by each wide characters in the column.

References: https://todo.sr.ht/~rjarry/aerc/22
Signed-off-by: Pinghao Wu <xdavidwuph@gmail.com>
Acked-by: Koni Marti <koni.marti@gmail.com>
2022-07-30 17:58:26 +02:00

414 lines
9.8 KiB
Go

package format
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
"unicode"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/mail"
"github.com/mattn/go-runewidth"
)
// AddressForHumans formats the address. If the address's name
// contains non-ASCII characters it will be quoted but not encoded.
// Meant for display purposes to the humans, not for sending over the wire.
func AddressForHumans(a *mail.Address) string {
if a.Name != "" {
if atom.MatchString(a.Name) {
return fmt.Sprintf("%s <%s>", a.Name, a.Address)
} else {
return fmt.Sprintf("\"%s\" <%s>",
strings.ReplaceAll(a.Name, "\"", "'"), a.Address)
}
} else {
return fmt.Sprintf("<%s>", a.Address)
}
}
var atom *regexp.Regexp = regexp.MustCompile("^[a-z0-9!#$%7'*+-/=?^_`{}|~ ]+$")
// FormatAddresses formats a list of addresses into a human readable string
func FormatAddresses(l []*mail.Address) string {
formatted := make([]string, len(l))
for i, a := range l {
formatted[i] = AddressForHumans(a)
}
return strings.Join(formatted, ", ")
}
// CompactPath reduces a directory path into a compact form. The directory
// name will be split with the provided separator and each part will be reduced
// to the first letter in its name: INBOX/01_WORK/PROJECT will become
// I/W/PROJECT.
func CompactPath(name string, sep rune) (compact string) {
parts := strings.Split(name, string(sep))
for i, part := range parts {
if i == len(parts)-1 {
compact += part
} else {
if len(part) != 0 {
r := part[0]
for i := 0; i < len(part)-1; i++ {
if unicode.IsLetter(rune(part[i])) {
r = part[i]
break
}
}
compact += fmt.Sprintf("%c%c", r, sep)
} else {
compact += fmt.Sprintf("%c", sep)
}
}
}
return
}
type Ctx struct {
FromAddress string
AccountName string
MsgNum int
MsgInfo *models.MessageInfo
MsgIsMarked bool
// UI controls for threading
ThreadPrefix string
ThreadSameSubject bool
}
func ParseMessageFormat(format string, timeFmt string, thisDayTimeFmt string,
thisWeekTimeFmt string, thisYearTimeFmt string, ctx Ctx) (
string, []interface{}, error) {
retval := make([]byte, 0, len(format))
var args []interface{}
accountFromAddress, err := mail.ParseAddress(ctx.FromAddress)
if err != nil {
return "", nil, err
}
envelope := ctx.MsgInfo.Envelope
if envelope == nil {
return "", nil,
errors.New("no envelope available for this message")
}
var c rune
for i, ni := 0, 0; i < len(format); {
ni = strings.IndexByte(format[i:], '%')
if ni < 0 {
ni = len(format)
retval = append(retval, []byte(format[i:ni])...)
break
}
ni += i + 1
// Check for fmt flags
if ni == len(format) {
goto handle_end_error
}
c = rune(format[ni])
if c == '+' || c == '-' || c == '#' || c == ' ' || c == '0' {
ni++
}
// Check for precision and width
if ni == len(format) {
goto handle_end_error
}
c = rune(format[ni])
for unicode.IsDigit(c) {
ni++
c = rune(format[ni])
}
if c == '.' {
ni++
c = rune(format[ni])
for unicode.IsDigit(c) {
ni++
c = rune(format[ni])
}
}
retval = append(retval, []byte(format[i:ni])...)
// Get final format verb
if ni == len(format) {
goto handle_end_error
}
c = rune(format[ni])
switch c {
case '%':
retval = append(retval, '%')
case 'a':
if len(envelope.From) == 0 {
return "", nil,
errors.New("found no address for sender")
}
addr := envelope.From[0]
retval = append(retval, 's')
args = append(args, addr.Address)
case 'A':
var addr *mail.Address
if len(envelope.ReplyTo) == 0 {
if len(envelope.From) == 0 {
return "", nil,
errors.New("found no address for sender or reply-to")
} else {
addr = envelope.From[0]
}
} else {
addr = envelope.ReplyTo[0]
}
retval = append(retval, 's')
args = append(args, addr.Address)
case 'C':
retval = append(retval, 'd')
args = append(args, ctx.MsgNum)
case 'd':
date := envelope.Date
if date.IsZero() {
date = ctx.MsgInfo.InternalDate
}
retval = append(retval, 's')
args = append(args,
dummyIfZeroDate(date.Local(),
timeFmt, thisDayTimeFmt,
thisWeekTimeFmt, thisYearTimeFmt))
case 'D':
date := envelope.Date
if date.IsZero() {
date = ctx.MsgInfo.InternalDate
}
retval = append(retval, 's')
args = append(args,
dummyIfZeroDate(date.Local(),
timeFmt, thisDayTimeFmt,
thisWeekTimeFmt, thisYearTimeFmt))
case 'f':
if len(envelope.From) == 0 {
return "", nil,
errors.New("found no address for sender")
}
addr := AddressForHumans(envelope.From[0])
retval = append(retval, 's')
args = append(args, addr)
case 'F':
if len(envelope.From) == 0 {
return "", nil,
errors.New("found no address for sender")
}
addr := envelope.From[0]
var val string
if addr.Name == accountFromAddress.Name && len(envelope.To) != 0 {
addr = envelope.To[0]
}
if addr.Name != "" {
val = addr.Name
} else {
val = addr.Address
}
retval = append(retval, 's')
args = append(args, val)
case 'g':
retval = append(retval, 's')
args = append(args, strings.Join(ctx.MsgInfo.Labels, ", "))
case 'i':
retval = append(retval, 's')
args = append(args, envelope.MessageId)
case 'n':
if len(envelope.From) == 0 {
return "", nil,
errors.New("found no address for sender")
}
addr := envelope.From[0]
var val string
if addr.Name != "" {
val = addr.Name
} else {
val = addr.Address
}
retval = append(retval, 's')
args = append(args, val)
case 'r':
addrs := FormatAddresses(envelope.To)
retval = append(retval, 's')
args = append(args, addrs)
case 'R':
addrs := FormatAddresses(envelope.Cc)
retval = append(retval, 's')
args = append(args, addrs)
case 's':
retval = append(retval, 's')
// if we are threaded strip the repeated subjects unless it's the
// first on the screen
subject := envelope.Subject
if ctx.ThreadSameSubject {
subject = ""
}
args = append(args, ctx.ThreadPrefix+subject)
case 't':
if len(envelope.To) == 0 {
return "", nil,
errors.New("found no address for recipient")
}
addr := envelope.To[0]
retval = append(retval, 's')
args = append(args, addr.Address)
case 'T':
retval = append(retval, 's')
args = append(args, ctx.AccountName)
case 'u':
if len(envelope.From) == 0 {
return "", nil,
errors.New("found no address for sender")
}
addr := envelope.From[0]
mailbox := addr.Address // fallback if there's no @ sign
if split := strings.SplitN(addr.Address, "@", 2); len(split) == 2 {
mailbox = split[1]
}
retval = append(retval, 's')
args = append(args, mailbox)
case 'v':
if len(envelope.From) == 0 {
return "", nil,
errors.New("found no address for sender")
}
addr := envelope.From[0]
// check if message is from current user
if addr.Name != "" {
retval = append(retval, 's')
args = append(args,
strings.Split(addr.Name, " ")[0])
}
case 'Z':
// calculate all flags
var readReplyFlag = ""
var delFlag = ""
var flaggedFlag = ""
var markedFlag = ""
seen := false
recent := false
answered := false
for _, flag := range ctx.MsgInfo.Flags {
if flag == models.SeenFlag {
seen = true
} else if flag == models.RecentFlag {
recent = true
} else if flag == models.AnsweredFlag {
answered = true
}
if flag == models.DeletedFlag {
delFlag = "D"
// TODO: check if attachments
}
if flag == models.FlaggedFlag {
flaggedFlag = "!"
}
// TODO: check gpg stuff
}
if seen {
if answered {
readReplyFlag = "r" // message has been replied to
}
} else {
if recent {
readReplyFlag = "N" // message is new
} else {
readReplyFlag = "O" // message is old
}
}
if ctx.MsgIsMarked {
markedFlag = "*"
}
retval = append(retval, '4', 's')
args = append(args, readReplyFlag+delFlag+flaggedFlag+markedFlag)
// Move the below cases to proper alphabetical positions once
// implemented
case 'l':
// TODO: number of lines in the message
retval = append(retval, 'd')
args = append(args, ctx.MsgInfo.Size)
case 'e':
// TODO: current message number in thread
fallthrough
case 'E':
// TODO: number of messages in current thread
fallthrough
case 'H':
// TODO: spam attribute(s) of this message
fallthrough
case 'L':
// TODO:
fallthrough
case 'X':
// TODO: number of attachments
fallthrough
case 'y':
// TODO: X-Label field
fallthrough
case 'Y':
// TODO: X-Label field and some other constraints
fallthrough
default:
// Just ignore it and print as is
// so %k in index format becomes %%k to Printf
retval = append(retval, '%')
retval = append(retval, byte(c))
}
i = ni + 1
}
const zeroWidthSpace rune = '\u200b'
for i, val := range args {
if s, ok := val.(string); ok {
var out strings.Builder
for _, r := range s {
w := runewidth.RuneWidth(r)
for w > 1 {
out.WriteRune(zeroWidthSpace)
w -= 1
}
out.WriteRune(r)
}
args[i] = out.String()
}
}
return string(retval), args, nil
handle_end_error:
return "", nil,
errors.New("reached end of string while parsing message format")
}
func dummyIfZeroDate(date time.Time, format string, todayFormat string,
thisWeekFormat string, thisYearFormat string) string {
if date.IsZero() {
return strings.Repeat("?", len(format))
}
year := date.Year()
day := date.YearDay()
now := time.Now()
thisYear := now.Year()
thisDay := now.YearDay()
if year == thisYear {
if day == thisDay && todayFormat != "" {
return date.Format(todayFormat)
}
if day > thisDay-7 && thisWeekFormat != "" {
return date.Format(thisWeekFormat)
}
if thisYearFormat != "" {
return date.Format(thisYearFormat)
}
}
return date.Format(format)
}