diff --git a/config/config.go b/config/config.go index f1569b617ca83ca3677a95b4a1b5a8207ee3aa62..9288e4c49a7fc86d964499453f23a89f1e0ae371 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/google/uuid" "github.com/zalando/go-keyring" @@ -12,6 +13,21 @@ import ( const keyringServiceName = "matcha-email-client" +// Date format presets use human-readable tokens. Supported tokens: +// +// YYYY (4-digit year), YY (2-digit year) +// MM (month, or minutes when following an hour token + colon) +// mm (minutes, explicit) +// DD (day) +// HH (24-hour), hh (12-hour, zero-padded) +// SS, ss (seconds) +// AM, PM (meridiem marker) +const ( + DateFormatISO = "YYYY-MM-DD HH:MM" + DateFormatUS = "MM/DD/YYYY hh:MM AM" + DateFormatEU = "DD/MM/YYYY HH:MM" +) + // Account stores the configuration for a single email account. type Account struct { ID string `json:"id"` @@ -69,6 +85,67 @@ type Config struct { DisableNotifications bool `json:"disable_notifications,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` + DateFormat string `json:"date_format,omitempty"` +} + +// GetDateFormat returns the Go time reference layout translated from the +// user's configured human-readable format. Defaults to EU when unset. +func (c *Config) GetDateFormat() string { + f := c.DateFormat + if f == "" { + f = DateFormatEU + } + return translateDateFormat(f) +} + +// translateDateFormat converts a human-readable format string (e.g. +// "DD/MM/YYYY HH:MM") into a Go reference-time layout usable by +// time.Format. MM is disambiguated by context: when it directly follows +// an hour token plus ":", it maps to minutes; otherwise to month. +func translateDateFormat(f string) string { + var b strings.Builder + i := 0 + for i < len(f) { + rest := f[i:] + switch { + case strings.HasPrefix(rest, "YYYY"): + b.WriteString("2006") + i += 4 + case strings.HasPrefix(rest, "YY"): + b.WriteString("06") + i += 2 + case strings.HasPrefix(rest, "DD"): + b.WriteString("02") + i += 2 + case strings.HasPrefix(rest, "HH"): + b.WriteString("15") + i += 2 + case strings.HasPrefix(rest, "hh"): + b.WriteString("03") + i += 2 + case strings.HasPrefix(rest, "mm"): + b.WriteString("04") + i += 2 + case strings.HasPrefix(rest, "SS"), strings.HasPrefix(rest, "ss"): + b.WriteString("05") + i += 2 + case strings.HasPrefix(rest, "MM"): + cur := b.String() + if strings.HasSuffix(cur, "15:") || strings.HasSuffix(cur, "03:") { + b.WriteString("04") + } else { + b.WriteString("01") + } + i += 2 + case strings.HasPrefix(rest, "AM"), strings.HasPrefix(rest, "PM"): + b.WriteString("PM") + i += 2 + default: + b.WriteByte(f[i]) + i++ + } + } + return b.String() } // GetIMAPServer returns the IMAP server address for the account. @@ -291,6 +368,7 @@ type secureDiskConfig struct { DisableNotifications bool `json:"disable_notifications,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` + DateFormat string `json:"date_format,omitempty"` } // SaveConfig saves the given configuration to the config file and passwords to the keyring. @@ -326,6 +404,7 @@ func SaveConfig(config *Config) error { DisableNotifications: config.DisableNotifications, Theme: config.Theme, MailingLists: config.MailingLists, + DateFormat: config.DateFormat, } for _, acc := range config.Accounts { sdc.Accounts = append(sdc.Accounts, secureDiskAccount{ @@ -417,6 +496,7 @@ func LoadConfig() (*Config, error) { DisableNotifications bool `json:"disable_notifications,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` + DateFormat string `json:"date_format,omitempty"` } var raw diskConfig @@ -449,6 +529,7 @@ func LoadConfig() (*Config, error) { config.DisableNotifications = raw.DisableNotifications config.Theme = raw.Theme config.MailingLists = raw.MailingLists + config.DateFormat = raw.DateFormat for _, rawAcc := range raw.Accounts { acc := Account{ ID: rawAcc.ID, diff --git a/config/config_test.go b/config/config_test.go index 6c68d92baaad8f830f7f119446a53d39d380faf7..9d7bd5d789e44045c632928fe10e708a0f7f6ccf 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "reflect" "testing" + "time" "github.com/zalando/go-keyring" ) @@ -312,3 +313,49 @@ func TestAccountSendIdentityHelpers(t *testing.T) { } }) } + +func TestTranslateDateFormat(t *testing.T) { + testCases := []struct { + name string + input string + want string + sample string // expected output of time.Format for a fixed sample instant + }{ + {"EU preset", DateFormatEU, "02/01/2006 15:04", "17/04/2026 09:05"}, + {"US preset", DateFormatUS, "01/02/2006 03:04 PM", "04/17/2026 09:05 AM"}, + {"ISO preset", DateFormatISO, "2006-01-02 15:04", "2026-04-17 09:05"}, + {"seconds", "HH:MM:SS", "15:04:05", "09:05:00"}, + {"explicit minutes", "YYYY-MM-DD HH:mm", "2006-01-02 15:04", "2026-04-17 09:05"}, + {"2-digit year", "DD/MM/YY", "02/01/06", "17/04/26"}, + {"literal passthrough", "some text", "some text", "some text"}, + } + + sample := time.Date(2026, 4, 17, 9, 5, 0, 0, time.UTC) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := translateDateFormat(tc.input) + if got != tc.want { + t.Fatalf("translateDateFormat(%q) = %q, want %q", tc.input, got, tc.want) + } + if rendered := sample.Format(got); rendered != tc.sample { + t.Fatalf("sample.Format(%q) = %q, want %q", got, rendered, tc.sample) + } + }) + } +} + +func TestConfigGetDateFormatDefault(t *testing.T) { + c := &Config{} + got := c.GetDateFormat() + want := translateDateFormat(DateFormatEU) + if got != want { + t.Fatalf("GetDateFormat() with empty DateFormat = %q, want %q", got, want) + } +} + +func TestConfigGetDateFormatCustom(t *testing.T) { + c := &Config{DateFormat: "DD/MM/YYYY HH:MM"} + if got, want := c.GetDateFormat(), "02/01/2006 15:04"; got != want { + t.Fatalf("GetDateFormat() = %q, want %q", got, want) + } +} diff --git a/main.go b/main.go index b56986024edd55d43e6997feb8f480f547f6612f..d25023bc34dc180563e39704bcec58fcecbf9190 100644 --- a/main.go +++ b/main.go @@ -381,6 +381,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cachedFolders = []string{"INBOX"} } m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts) + m.folderInbox.SetDateFormat(m.config.GetDateFormat()) // Use cached INBOX emails for instant display (memory first, then disk) if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 { m.folderInbox.SetEmails(cached, m.config.Accounts) diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index e48b219f085234dd542fee969f74bb9af5fe9b40..e12f8979930bde833ded59373167a9f10eee2107 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -96,6 +96,13 @@ func sortFolders(folders []string) []string { return sorted } +// SetDateFormat propagates the configured date layout to the inner inbox. +func (m *FolderInbox) SetDateFormat(layout string) { + if m.inbox != nil { + m.inbox.SetDateFormat(layout) + } +} + // NewFolderInbox creates a new FolderInbox with the given folders and accounts. func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox { folders = sortFolders(folders) diff --git a/tui/inbox.go b/tui/inbox.go index fcdd439646a41a06fc9c61b382b2306e42688014..71f132cec93198bc2acb66543e41e8b2f4e8b617 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -74,7 +74,11 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } // Format and right-align date - dateStr := formatRelativeDate(i.date) + layout := "" + if d.inbox != nil { + layout = d.inbox.dateFormat + } + dateStr := formatRelativeDate(i.date, layout) styledDate := dateStyle.Render(dateStr) if i.isRead { styledDate = readEmailStyle.Render(dateStr) @@ -159,8 +163,9 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } // formatRelativeDate formats a time as relative if within the last week, -// otherwise as an absolute date. -func formatRelativeDate(t time.Time) string { +// otherwise as an absolute date using the caller-supplied Go time layout. +// When layout is empty, falls back to the built-in short/long defaults. +func formatRelativeDate(t time.Time, layout string) string { if t.IsZero() { return "" } @@ -189,6 +194,9 @@ func formatRelativeDate(t time.Time) string { } return fmt.Sprintf("%d days ago", days) default: + if layout != "" { + return t.Format(layout) + } if t.Year() == now.Year() { return t.Format("Jan 02") } @@ -269,6 +277,17 @@ type Inbox struct { visualAnchor int // Index where visual selection started selectedUIDs map[uint32]string // map[uid]accountID for selected emails selectionOrder []uint32 // Ordered list of UIDs for display + + // dateFormat is the Go reference-time layout used for absolute dates + // older than a week. When empty, the built-in defaults apply. + dateFormat string +} + +// SetDateFormat configures the Go time layout used to render absolute +// dates in the email list. Pass the value returned by +// config.Config.GetDateFormat. +func (m *Inbox) SetDateFormat(layout string) { + m.dateFormat = layout } func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {