218 lines
5.9 KiB
Go
218 lines
5.9 KiB
Go
package timeparse
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrEmptyDate = errors.New("empty date")
|
|
ErrInvalidDate = errors.New("invalid date")
|
|
ErrEmptyDateTime = errors.New("empty date/time")
|
|
ErrInvalidDateTime = errors.New("invalid date/time")
|
|
ErrEmptyTimeExpr = errors.New("empty time expression")
|
|
ErrInvalidTimeExpr = errors.New("invalid time expression")
|
|
ErrEmptySince = errors.New("empty since value")
|
|
ErrInvalidSince = errors.New("invalid since value")
|
|
ErrInvalidDateLayouts = errors.New("invalid date format")
|
|
)
|
|
|
|
// ParsedDateTime represents a parsed time expression and whether the input
|
|
// carried an explicit clock component.
|
|
type ParsedDateTime struct {
|
|
Time time.Time
|
|
HasTime bool
|
|
}
|
|
|
|
// SinceResult keeps normalized "since" values, preserving RFC3339Nano output
|
|
// when the input used fractional seconds.
|
|
type SinceResult struct {
|
|
Time time.Time
|
|
UseRFC3339Nano bool
|
|
}
|
|
|
|
// ParseDate parses a strict date in YYYY-MM-DD format.
|
|
func ParseDate(value string) (time.Time, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}, ErrEmptyDate
|
|
}
|
|
|
|
t, err := time.Parse("2006-01-02", value)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("%w: %q", ErrInvalidDate, value)
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
// ParseDateTimeOrDate parses flexible date inputs commonly used across commands.
|
|
// Supported: RFC3339/RFC3339Nano, ISO-8601 numeric offset (-0800),
|
|
// YYYY-MM-DD, and local datetime layouts without timezone.
|
|
func ParseDateTimeOrDate(value string, loc *time.Location) (ParsedDateTime, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return ParsedDateTime{}, ErrEmptyDateTime
|
|
}
|
|
|
|
if loc == nil {
|
|
loc = time.Local
|
|
}
|
|
|
|
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
|
return ParsedDateTime{Time: t, HasTime: true}, nil
|
|
}
|
|
|
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return ParsedDateTime{Time: t, HasTime: true}, nil
|
|
}
|
|
|
|
if t, err := time.Parse("2006-01-02T15:04:05-0700", value); err == nil {
|
|
return ParsedDateTime{Time: t, HasTime: true}, nil
|
|
}
|
|
|
|
if t, err := time.ParseInLocation("2006-01-02", value, loc); err == nil {
|
|
return ParsedDateTime{Time: t, HasTime: false}, nil
|
|
}
|
|
|
|
for _, layout := range []string{
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02T15:04",
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02 15:04",
|
|
} {
|
|
if t, err := time.ParseInLocation(layout, value, loc); err == nil {
|
|
return ParsedDateTime{Time: t, HasTime: true}, nil
|
|
}
|
|
}
|
|
|
|
return ParsedDateTime{}, fmt.Errorf("%w: %q", ErrInvalidDateTime, value)
|
|
}
|
|
|
|
// ParseRangeExpr parses calendar range expressions.
|
|
// Supported: absolute datetime/date forms from ParseDateTimeOrDate plus
|
|
// relative forms (now/today/tomorrow/yesterday/monday/next monday).
|
|
func ParseRangeExpr(expr string, now time.Time, loc *time.Location) (time.Time, error) {
|
|
expr = strings.TrimSpace(expr)
|
|
if expr == "" {
|
|
return time.Time{}, ErrEmptyTimeExpr
|
|
}
|
|
|
|
exprLower := strings.ToLower(expr)
|
|
switch exprLower {
|
|
case "now":
|
|
return now, nil
|
|
case "today":
|
|
return startOfDay(now), nil
|
|
case "tomorrow":
|
|
return startOfDay(now.AddDate(0, 0, 1)), nil
|
|
case "yesterday":
|
|
return startOfDay(now.AddDate(0, 0, -1)), nil
|
|
}
|
|
|
|
if t, ok := ParseWeekdayExpr(exprLower, now); ok {
|
|
return t, nil
|
|
}
|
|
|
|
parsed, err := ParseDateTimeOrDate(expr, loc)
|
|
if err == nil {
|
|
return parsed.Time, nil
|
|
}
|
|
|
|
return time.Time{}, fmt.Errorf("%w: %q (try: 2026-01-05, today, tomorrow, monday)", ErrInvalidTimeExpr, expr)
|
|
}
|
|
|
|
// ParseSince parses --since values for tracking style queries.
|
|
// Supported: duration (24h), date (YYYY-MM-DD), RFC3339(+nano), and
|
|
// local datetime layouts.
|
|
func ParseSince(value string, now time.Time, loc *time.Location) (SinceResult, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return SinceResult{}, ErrEmptySince
|
|
}
|
|
|
|
if d, err := time.ParseDuration(value); err == nil {
|
|
return SinceResult{Time: now.Add(-d).UTC()}, nil
|
|
}
|
|
|
|
if t, err := ParseDate(value); err == nil {
|
|
return SinceResult{Time: t.UTC()}, nil
|
|
}
|
|
|
|
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
|
return SinceResult{Time: t.UTC(), UseRFC3339Nano: strings.Contains(value, ".")}, nil
|
|
}
|
|
|
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return SinceResult{Time: t.UTC()}, nil
|
|
}
|
|
|
|
parsed, err := ParseDateTimeOrDate(value, loc)
|
|
if err == nil {
|
|
return SinceResult{Time: parsed.Time.UTC()}, nil
|
|
}
|
|
|
|
return SinceResult{}, fmt.Errorf("%w: %q", ErrInvalidSince, value)
|
|
}
|
|
|
|
var weekdayNames = map[string]time.Weekday{
|
|
"sunday": time.Sunday,
|
|
"sun": time.Sunday,
|
|
"monday": time.Monday,
|
|
"mon": time.Monday,
|
|
"tuesday": time.Tuesday,
|
|
"tue": time.Tuesday,
|
|
"tues": time.Tuesday,
|
|
"wednesday": time.Wednesday,
|
|
"wed": time.Wednesday,
|
|
"thursday": time.Thursday,
|
|
"thu": time.Thursday,
|
|
"thur": time.Thursday,
|
|
"thurs": time.Thursday,
|
|
"friday": time.Friday,
|
|
"fri": time.Friday,
|
|
"saturday": time.Saturday,
|
|
"sat": time.Saturday,
|
|
}
|
|
|
|
// ParseWeekdayName parses a weekday name or common alias.
|
|
func ParseWeekdayName(value string) (time.Weekday, bool) {
|
|
wd, ok := weekdayNames[strings.ToLower(strings.TrimSpace(value))]
|
|
return wd, ok
|
|
}
|
|
|
|
// ParseWeekdayExpr parses weekday expressions like "monday" or "next tuesday".
|
|
func ParseWeekdayExpr(expr string, now time.Time) (time.Time, bool) {
|
|
expr = strings.TrimSpace(expr)
|
|
|
|
next := false
|
|
if strings.HasPrefix(expr, "next ") {
|
|
next = true
|
|
expr = strings.TrimPrefix(expr, "next ")
|
|
}
|
|
|
|
targetDay, ok := ParseWeekdayName(expr)
|
|
if !ok {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
currentDay := now.Weekday()
|
|
|
|
daysUntil := int(targetDay) - int(currentDay)
|
|
if daysUntil < 0 || (daysUntil == 0 && next) {
|
|
daysUntil += 7
|
|
}
|
|
|
|
if daysUntil == 0 {
|
|
return startOfDay(now), true
|
|
}
|
|
|
|
return startOfDay(now.AddDate(0, 0, daysUntil)), true
|
|
}
|
|
|
|
func startOfDay(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
}
|