c3bb3aa2a8
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>
421 lines
9.9 KiB
Go
421 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)
|
|
}
|