internal/feed/normalize/dates.go

// Package normalize converts timestamps between the formats the tilstream
// feed writers need. Every writer is happy with RFC3339, but RSS 2.0
// readers still expect RFC1123Z, and JSON Feed readers tolerate either.
package normalize

import (
	"fmt"
	"strings"
	"time"
)

// ParseFlexible attempts to parse a timestamp in one of the usual formats.
// It returns the first successful interpretation.
func ParseFlexible(s string) (time.Time, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return time.Time{}, fmt.Errorf("normalize: empty date string")
	}
	formats := []string{
		time.RFC3339Nano,
		time.RFC3339,
		time.RFC1123Z,
		time.RFC1123,
		"2006-01-02T15:04:05",
		"2006-01-02 15:04:05",
		"2006-01-02",
		"2006/01/02",
	}
	var firstErr error
	for _, f := range formats {
		t, err := time.Parse(f, s)
		if err == nil {
			return t, nil
		}
		if firstErr == nil {
			firstErr = err
		}
	}
	return time.Time{}, fmt.Errorf("normalize: could not parse %q: %w", s, firstErr)
}

// FormatRFC1123Z returns the RSS 2.0 date string, or an empty string for
// the zero time.
func FormatRFC1123Z(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.UTC().Format(time.RFC1123Z)
}

// FormatRFC3339 returns the Atom/JSON Feed date string.
func FormatRFC3339(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.UTC().Format(time.RFC3339)
}

// FormatHumanDate formats t as "Jan 2, 2006" which I use in templates.
func FormatHumanDate(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.Format("Jan 2, 2006")
}

// MustParse is a test helper that panics on parse failure.
func MustParse(s string) time.Time {
	t, err := ParseFlexible(s)
	if err != nil {
		panic(err)
	}
	return t
}

// TruncateToDay returns t with its time-of-day stripped, in its location.
func TruncateToDay(t time.Time) time.Time {
	y, m, d := t.Date()
	return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}

// ISOWeek returns a pair (year, week) for t, following ISO 8601.
func ISOWeek(t time.Time) (int, int) {
	return t.ISOWeek()
}

// SortDescending sorts a slice of times in place, newest first.
func SortDescending(ts []time.Time) {
	// Simple insertion sort; len is expected to be small (<= 2 per item).
	for i := 1; i < len(ts); i++ {
		j := i
		for j > 0 && ts[j].After(ts[j-1]) {
			ts[j], ts[j-1] = ts[j-1], ts[j]
			j--
		}
	}
}

// CoerceZone interprets naive times in the given location. If t already
// has a non-UTC location it is returned unchanged.
func CoerceZone(t time.Time, loc *time.Location) time.Time {
	if loc == nil || t.IsZero() || t.Location() != time.UTC {
		return t
	}
	return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
}