aerc/lib/format/format.go
Bence Ferdinandy c3bb3aa2a8 msgview: add separate date formatting
The ThisDayTimeFormat and friends are missing from the message view
which just uses the message list's default setting. This might not be
desirable since the amount of space available is different. Introduce
separate settings for formatting dates in the message view.

Signed-off-by: Bence Ferdinandy <bence@ferdinandy.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-10-19 20:27:58 +02:00

422 lines
9.9 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,
) {
if ctx.MsgInfo.Error != nil {
return "", nil,
errors.New("(unable to fetch header)")
}
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
readReplyFlag := ""
delFlag := ""
flaggedFlag := ""
markedFlag := ""
seen := false
recent := false
answered := false
for _, flag := range ctx.MsgInfo.Flags {
switch flag {
case models.SeenFlag:
seen = true
case models.RecentFlag:
recent = true
case 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)
}