04fa9222d8
Add 33 new test functions covering annotations, undo system, history formatting, relative date display, and weekday parsing pipeline. Fix ISO date parsing to use ParseInLocation instead of Parse to respect the parser's timezone context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
493 lines
14 KiB
Go
493 lines
14 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DateParser handles all date/time/duration parsing with configurable options
|
|
type DateParser struct {
|
|
base time.Time
|
|
weekStart time.Weekday
|
|
}
|
|
|
|
// NewDateParser creates a new DateParser with the given base time and week start
|
|
func NewDateParser(base time.Time, weekStart time.Weekday) *DateParser {
|
|
return &DateParser{
|
|
base: base,
|
|
weekStart: weekStart,
|
|
}
|
|
}
|
|
|
|
// NewDefaultDateParser creates a DateParser with current time and Monday week start
|
|
func NewDefaultDateParser() *DateParser {
|
|
return &DateParser{
|
|
base: timeNow(),
|
|
weekStart: time.Monday,
|
|
}
|
|
}
|
|
|
|
// ParseDate is the main entry point for date parsing
|
|
// Handles: ISO dates, weekdays, month names, day+month, period boundaries, durations, time of day
|
|
func (p *DateParser) ParseDate(s string) (time.Time, error) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
|
|
// Check for time of day component (e.g., "mon:15:35" or "21jan:0800")
|
|
if dateStr, timeStr, hasTime := p.splitDateTime(s); hasTime {
|
|
dateVal, err := p.parseDateOnly(dateStr)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return p.parseTimeOfDay(dateVal, timeStr)
|
|
}
|
|
|
|
return p.parseDateOnly(s)
|
|
}
|
|
|
|
// parseDateOnly parses just the date component without time
|
|
func (p *DateParser) parseDateOnly(s string) (time.Time, error) {
|
|
// Empty string means "today" (used when parsing just time like "15:35")
|
|
if s == "" {
|
|
return time.Date(p.base.Year(), p.base.Month(), p.base.Day(), 0, 0, 0, 0, p.base.Location()), nil
|
|
}
|
|
|
|
// Try ISO format first
|
|
if t, err := time.ParseInLocation("2006-01-02", s, p.base.Location()); err == nil {
|
|
return t, nil
|
|
}
|
|
|
|
// Relative dates
|
|
if t, ok := p.parseRelative(s); ok {
|
|
return t, nil
|
|
}
|
|
|
|
// Weekday names
|
|
if t, ok := p.parseWeekday(s); ok {
|
|
return t, nil
|
|
}
|
|
|
|
// Month names (jan, january, feb, etc.)
|
|
if t, ok := p.parseMonthName(s); ok {
|
|
return t, nil
|
|
}
|
|
|
|
// Day+month format (21jan, Jan21, etc.)
|
|
if t, ok := p.parseDayMonth(s); ok {
|
|
return t, nil
|
|
}
|
|
|
|
// Period boundaries (sod, eod, sow, eow, etc.)
|
|
if t, ok := p.parsePeriodBoundary(s); ok {
|
|
return t, nil
|
|
}
|
|
|
|
// Special keywords (later, someday)
|
|
if t, ok := p.parseSpecialKeyword(s); ok {
|
|
return t, nil
|
|
}
|
|
|
|
// Duration as date offset (2d, 3w, etc.)
|
|
if t, ok := p.parseDurationAsDate(s); ok {
|
|
return t, nil
|
|
}
|
|
|
|
return time.Time{}, fmt.Errorf("unable to parse date: %s", s)
|
|
}
|
|
|
|
// splitDateTime checks if the string contains a time component
|
|
// Returns dateStr, timeStr, hasTime
|
|
func (p *DateParser) splitDateTime(s string) (string, string, bool) {
|
|
// Look for time patterns: HH:MM or HHMM at the end
|
|
// Examples: "mon:15:35", "21jan:0800", "15:35" (just time)
|
|
|
|
parts := strings.Split(s, ":")
|
|
if len(parts) < 2 {
|
|
return s, "", false
|
|
}
|
|
|
|
lastPart := parts[len(parts)-1]
|
|
|
|
// Format: "mon:15:35" or "tomorrow:15:35" (HH:MM with 3+ parts)
|
|
if len(parts) >= 3 {
|
|
// Check if last two parts form a valid time
|
|
secondLast := parts[len(parts)-2]
|
|
if h, err := strconv.Atoi(secondLast); err == nil && h >= 0 && h <= 23 {
|
|
if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 {
|
|
// HH:MM format
|
|
dateStr := strings.Join(parts[:len(parts)-2], ":")
|
|
if dateStr == "" {
|
|
dateStr = "today" // Just time means today
|
|
}
|
|
timeStr := secondLast + ":" + lastPart
|
|
return dateStr, timeStr, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Format with 2 parts: could be "15:35" (just time) or "mon:0800" (date+HHMM)
|
|
if len(parts) == 2 {
|
|
// Check if lastPart is HHMM format (4 digits)
|
|
if len(lastPart) == 4 {
|
|
if _, err := strconv.Atoi(lastPart); err == nil {
|
|
h, _ := strconv.Atoi(lastPart[0:2])
|
|
m, _ := strconv.Atoi(lastPart[2:4])
|
|
if h >= 0 && h <= 23 && m >= 0 && m <= 59 {
|
|
// Valid HHMM time
|
|
// If first part is also numeric, it's a date part (like "21jan:0800" but already lowercase)
|
|
// Otherwise it's a keyword like "tomorrow:0800"
|
|
return parts[0], lastPart, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if it's "HH:MM" format (both parts are 2-digit numbers)
|
|
if len(parts[0]) <= 2 && len(lastPart) <= 2 {
|
|
if h, err := strconv.Atoi(parts[0]); err == nil && h >= 0 && h <= 23 {
|
|
if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 {
|
|
// Just time, no date
|
|
return "", parts[0] + ":" + lastPart, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return s, "", false
|
|
}
|
|
|
|
// parseTimeOfDay applies time to a date
|
|
func (p *DateParser) parseTimeOfDay(date time.Time, timeStr string) (time.Time, error) {
|
|
var hour, minute int
|
|
var err error
|
|
|
|
if strings.Contains(timeStr, ":") {
|
|
// HH:MM format
|
|
parts := strings.Split(timeStr, ":")
|
|
if len(parts) != 2 {
|
|
return time.Time{}, fmt.Errorf("invalid time format: %s", timeStr)
|
|
}
|
|
hour, err = strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid hour: %s", parts[0])
|
|
}
|
|
minute, err = strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid minute: %s", parts[1])
|
|
}
|
|
} else {
|
|
// HHMM format
|
|
if len(timeStr) != 4 {
|
|
return time.Time{}, fmt.Errorf("invalid time format: %s (expected HHMM)", timeStr)
|
|
}
|
|
hour, err = strconv.Atoi(timeStr[0:2])
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid hour: %s", timeStr[0:2])
|
|
}
|
|
minute, err = strconv.Atoi(timeStr[2:4])
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid minute: %s", timeStr[2:4])
|
|
}
|
|
}
|
|
|
|
// Validate ranges
|
|
if hour < 0 || hour > 23 {
|
|
return time.Time{}, fmt.Errorf("hour must be 0-23: %d", hour)
|
|
}
|
|
if minute < 0 || minute > 59 {
|
|
return time.Time{}, fmt.Errorf("minute must be 0-59: %d", minute)
|
|
}
|
|
|
|
return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, date.Location()), nil
|
|
}
|
|
|
|
// parseRelative handles relative date keywords
|
|
func (p *DateParser) parseRelative(s string) (time.Time, bool) {
|
|
switch s {
|
|
case "now":
|
|
return p.base, true
|
|
case "today":
|
|
return time.Date(p.base.Year(), p.base.Month(), p.base.Day(), 0, 0, 0, 0, p.base.Location()), true
|
|
case "tomorrow":
|
|
tomorrow := p.base.AddDate(0, 0, 1)
|
|
return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()), true
|
|
case "yesterday":
|
|
yesterday := p.base.AddDate(0, 0, -1)
|
|
return time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()), true
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// parseWeekday handles weekday names (mon, monday, etc.)
|
|
func (p *DateParser) parseWeekday(s string) (time.Time, bool) {
|
|
weekdays := map[string]time.Weekday{
|
|
"sun": time.Sunday, "sunday": time.Sunday,
|
|
"mon": time.Monday, "monday": time.Monday,
|
|
"tue": time.Tuesday, "tuesday": time.Tuesday,
|
|
"wed": time.Wednesday, "wednesday": time.Wednesday,
|
|
"thu": time.Thursday, "thursday": time.Thursday,
|
|
"fri": time.Friday, "friday": time.Friday,
|
|
"sat": time.Saturday, "saturday": time.Saturday,
|
|
}
|
|
|
|
if targetWeekday, ok := weekdays[s]; ok {
|
|
return p.nextWeekday(targetWeekday), true
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// parseMonthName handles month names (jan, january, feb, february, etc.)
|
|
func (p *DateParser) parseMonthName(s string) (time.Time, bool) {
|
|
months := map[string]time.Month{
|
|
"jan": time.January, "january": time.January,
|
|
"feb": time.February, "february": time.February,
|
|
"mar": time.March, "march": time.March,
|
|
"apr": time.April, "april": time.April,
|
|
"may": time.May,
|
|
"jun": time.June, "june": time.June,
|
|
"jul": time.July, "july": time.July,
|
|
"aug": time.August, "august": time.August,
|
|
"sep": time.September, "september": time.September,
|
|
"oct": time.October, "october": time.October,
|
|
"nov": time.November, "november": time.November,
|
|
"dec": time.December, "december": time.December,
|
|
}
|
|
|
|
month, ok := months[s]
|
|
if !ok {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// Set to first of month
|
|
// If that date has passed this year, use next year
|
|
year := p.base.Year()
|
|
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location())
|
|
|
|
if firstOfMonth.Before(p.base) || firstOfMonth.Equal(p.base) {
|
|
// First of month has passed, use next year
|
|
year++
|
|
firstOfMonth = time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location())
|
|
}
|
|
|
|
return firstOfMonth, true
|
|
}
|
|
|
|
// parseDayMonth handles day+month formats (21jan, Jan21, 21January, January21)
|
|
func (p *DateParser) parseDayMonth(s string) (time.Time, bool) {
|
|
// Try pattern: digits followed by month name (21jan, 21January)
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] >= '0' && s[i] <= '9' {
|
|
continue
|
|
}
|
|
// Found first non-digit
|
|
if i > 0 && i < len(s) {
|
|
dayStr := s[:i]
|
|
monthStr := strings.ToLower(s[i:])
|
|
if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok {
|
|
return p.constructDate(day, month), true
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
// Try pattern: month name followed by digits (jan21, January21)
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] >= 'a' && s[i] <= 'z' || s[i] >= 'A' && s[i] <= 'Z' {
|
|
continue
|
|
}
|
|
// Found first non-letter
|
|
if i > 0 && i < len(s) {
|
|
monthStr := strings.ToLower(s[:i])
|
|
dayStr := s[i:]
|
|
if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok {
|
|
return p.constructDate(day, month), true
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// parseDayAndMonth extracts day and month from string parts
|
|
func (p *DateParser) parseDayAndMonth(dayStr, monthStr string) (int, time.Month, bool) {
|
|
day, err := strconv.Atoi(dayStr)
|
|
if err != nil || day < 1 || day > 31 {
|
|
return 0, 0, false
|
|
}
|
|
|
|
months := map[string]time.Month{
|
|
"jan": time.January, "january": time.January,
|
|
"feb": time.February, "february": time.February,
|
|
"mar": time.March, "march": time.March,
|
|
"apr": time.April, "april": time.April,
|
|
"may": time.May,
|
|
"jun": time.June, "june": time.June,
|
|
"jul": time.July, "july": time.July,
|
|
"aug": time.August, "august": time.August,
|
|
"sep": time.September, "september": time.September,
|
|
"oct": time.October, "october": time.October,
|
|
"nov": time.November, "november": time.November,
|
|
"dec": time.December, "december": time.December,
|
|
}
|
|
|
|
month, ok := months[monthStr]
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
|
|
return day, month, true
|
|
}
|
|
|
|
// constructDate creates a date with year logic (current year if future, next year if past)
|
|
func (p *DateParser) constructDate(day int, month time.Month) time.Time {
|
|
year := p.base.Year()
|
|
date := time.Date(year, month, day, 0, 0, 0, 0, p.base.Location())
|
|
|
|
// Validate day is valid for month (handles Feb 30, etc.)
|
|
if date.Month() != month {
|
|
// Invalid day for month, return zero time (will be caught as parse error)
|
|
return time.Time{}
|
|
}
|
|
|
|
if date.Before(p.base) || date.Equal(p.base) {
|
|
// Date has passed, use next year
|
|
year++
|
|
date = time.Date(year, month, day, 0, 0, 0, 0, p.base.Location())
|
|
}
|
|
|
|
return date
|
|
}
|
|
|
|
// parsePeriodBoundary handles period boundary keywords (sod, eod, sow, eow, etc.)
|
|
func (p *DateParser) parsePeriodBoundary(s string) (time.Time, bool) {
|
|
now := p.base
|
|
loc := now.Location()
|
|
|
|
switch s {
|
|
case "sod":
|
|
// Start of day - today at 00:00:00
|
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc), true
|
|
|
|
case "eod":
|
|
// End of day - today at 23:59:59
|
|
return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, loc), true
|
|
|
|
case "sow":
|
|
// Start of week - next occurrence of week start day at 00:00:00
|
|
return p.nextWeekday(p.weekStart), true
|
|
|
|
case "eow":
|
|
// End of week - next occurrence of week end day at 23:59:59
|
|
weekEnd := p.weekStart - 1
|
|
if weekEnd < 0 {
|
|
weekEnd = 6
|
|
}
|
|
endDate := p.nextWeekday(weekEnd)
|
|
return time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 0, loc), true
|
|
|
|
case "som":
|
|
// Start of month - 1st of current month at 00:00:00
|
|
// If we're past the 1st, use next month
|
|
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
|
if firstOfMonth.Before(now) || firstOfMonth.Equal(now) {
|
|
// Already past the 1st, use next month
|
|
firstOfMonth = time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc)
|
|
}
|
|
return firstOfMonth, true
|
|
|
|
case "eom":
|
|
// End of month - last day of current month at 23:59:59
|
|
// Get first day of next month, then subtract one day
|
|
firstOfNextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc)
|
|
lastOfMonth := firstOfNextMonth.AddDate(0, 0, -1)
|
|
return time.Date(lastOfMonth.Year(), lastOfMonth.Month(), lastOfMonth.Day(), 23, 59, 59, 0, loc), true
|
|
|
|
case "soy":
|
|
// Start of year - Jan 1 of current year at 00:00:00
|
|
// If we're past Jan 1, use next year
|
|
firstOfYear := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc)
|
|
if firstOfYear.Before(now) || firstOfYear.Equal(now) {
|
|
firstOfYear = time.Date(now.Year()+1, time.January, 1, 0, 0, 0, 0, loc)
|
|
}
|
|
return firstOfYear, true
|
|
|
|
case "eoy":
|
|
// End of year - Dec 31 of current year at 23:59:59
|
|
return time.Date(now.Year(), time.December, 31, 23, 59, 59, 0, loc), true
|
|
|
|
default:
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
|
|
// parseSpecialKeyword handles special keywords (later, someday)
|
|
func (p *DateParser) parseSpecialKeyword(s string) (time.Time, bool) {
|
|
switch s {
|
|
case "later", "someday":
|
|
// Far future: 2150-01-01 at 00:00:00
|
|
return time.Date(2150, time.January, 1, 0, 0, 0, 0, p.base.Location()), true
|
|
default:
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
|
|
// parseDurationAsDate handles duration as date offset (2d, 3w, etc.) and named durations
|
|
func (p *DateParser) parseDurationAsDate(s string) (time.Time, bool) {
|
|
// Named duration aliases
|
|
namedDurations := map[string]string{
|
|
"daily": "1d",
|
|
"weekly": "1w",
|
|
"monthly": "30d",
|
|
"yearly": "1y",
|
|
}
|
|
|
|
// Check for named duration
|
|
if pattern, ok := namedDurations[s]; ok {
|
|
s = pattern
|
|
}
|
|
|
|
// Try parsing as duration
|
|
duration, err := ParseRecurrencePattern(s)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// Add duration to base time
|
|
return p.base.Add(duration), true
|
|
}
|
|
|
|
// ParseDuration parses duration strings (1d, 2w, 5min, etc.)
|
|
func (p *DateParser) ParseDuration(s string) (time.Duration, error) {
|
|
// Use existing ParseRecurrencePattern for now (will expand in Phase 2)
|
|
return ParseRecurrencePattern(s)
|
|
}
|
|
|
|
// nextWeekday returns the next occurrence of the target weekday
|
|
// Smart logic: if today is Thursday and target is Sunday, returns this Sunday
|
|
// If today is Sunday and target is Sunday, returns next Sunday
|
|
func (p *DateParser) nextWeekday(target time.Weekday) time.Time {
|
|
from := p.base
|
|
// Calculate days until target
|
|
daysUntil := int(target - from.Weekday())
|
|
|
|
if daysUntil <= 0 {
|
|
daysUntil += 7 // Next week
|
|
}
|
|
|
|
next := from.AddDate(0, 0, daysUntil)
|
|
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
|
|
}
|
|
|
|
// ParseDate is the public API that uses config settings
|
|
func ParseDate(s string) (time.Time, error) {
|
|
cfg, err := GetConfig()
|
|
weekStart := time.Monday // default
|
|
if err == nil {
|
|
weekStart = cfg.GetWeekStart()
|
|
}
|
|
|
|
parser := NewDateParser(timeNow(), weekStart)
|
|
return parser.ParseDate(s)
|
|
}
|