Phase 1: Refactor DateParser structure
- Create DateParser struct with configurable base time and week start - Add placeholder methods for new parsing features - Implement time-of-day parsing foundation (splitDateTime, parseTimeOfDay) - Maintain backward compatibility with existing ParseDate() function - Update tests to use new DateParser API - All 33 existing tests passing
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# Time parsing
|
||||||
|
|
||||||
|
## Time formats
|
||||||
|
- mon, monday - sun, sunday
|
||||||
|
- 21jan, 30dec etc
|
||||||
|
- 2015-12-21
|
||||||
|
- jan, feb, etc. - Sets to first of month
|
||||||
|
- now, current date and time
|
||||||
|
- today, tomorrow, yesterday - at time 00:00
|
||||||
|
- sod, sow, som, soy - start of X at time 00:00
|
||||||
|
- eod, eow, eom, eoy - end of X at time 23:59
|
||||||
|
- later, someday - 2150-01-01 at time 00:00
|
||||||
|
|
||||||
|
All time formats should also support time of day, mon:15:35 and mon:1535. 24-hour format. Mind the ':', must be seperated from the attribute:value syntax.
|
||||||
|
|
||||||
|
## Duration formats
|
||||||
|
- 5sec, second, seconds
|
||||||
|
- 5min, minute, minutes
|
||||||
|
- 5hrs, hour, hours
|
||||||
|
- 3d[ays], 2w[eeks], 4m[onths], 1y[ear]. Singular if 1, plural >1
|
||||||
|
- daily, weekly, monthly (30days), yearly
|
||||||
|
|
||||||
|
There is indirect support for durations everywhere that a date value is expected.
|
||||||
|
No ordinal assumes 1 (hours = hrs = hour = 60min)
|
||||||
|
|
||||||
|
## Relative formats
|
||||||
|
One attribute can be relative to another:
|
||||||
|
`opal add Buy milk due:mon wait:due-1d`
|
||||||
@@ -2,34 +2,199 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseDate parses date strings with smart interpretation
|
// DateParser handles all date/time/duration parsing with configurable options
|
||||||
// Supports: ISO dates, relative (tomorrow, today), weekdays (sun, monday)
|
type DateParser struct {
|
||||||
func ParseDate(s string) (time.Time, error) {
|
base time.Time
|
||||||
s = strings.ToLower(strings.TrimSpace(s))
|
weekStart time.Weekday
|
||||||
now := timeNow()
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
// Try ISO format first
|
// Try ISO format first
|
||||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relative dates
|
// Relative dates
|
||||||
switch s {
|
if t, ok := p.parseRelative(s); ok {
|
||||||
case "today":
|
return t, nil
|
||||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), nil
|
|
||||||
case "tomorrow":
|
|
||||||
tomorrow := now.AddDate(0, 0, 1)
|
|
||||||
return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()), nil
|
|
||||||
case "yesterday":
|
|
||||||
yesterday := now.AddDate(0, 0, -1)
|
|
||||||
return time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weekday names
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check last part for time pattern (2-4 digits)
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
if len(lastPart) == 2 || len(lastPart) == 4 {
|
||||||
|
// Could be minutes (HH:MM) or HHMM format
|
||||||
|
if _, err := strconv.Atoi(lastPart); err == nil {
|
||||||
|
// Last part is numeric, could be time
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Format: "15:35" (just time, no date)
|
||||||
|
return "", s, true
|
||||||
|
} else if len(parts) >= 3 {
|
||||||
|
// Format: "mon:15:35" or similar
|
||||||
|
secondLast := parts[len(parts)-2]
|
||||||
|
if _, err := strconv.Atoi(secondLast); err == nil {
|
||||||
|
// HH:MM format
|
||||||
|
dateStr := strings.Join(parts[:len(parts)-2], ":")
|
||||||
|
timeStr := secondLast + ":" + lastPart
|
||||||
|
return dateStr, timeStr, true
|
||||||
|
} else if len(lastPart) == 4 {
|
||||||
|
// HHMM format
|
||||||
|
dateStr := strings.Join(parts[:len(parts)-1], ":")
|
||||||
|
return dateStr, 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{
|
weekdays := map[string]time.Weekday{
|
||||||
"sun": time.Sunday, "sunday": time.Sunday,
|
"sun": time.Sunday, "sunday": time.Sunday,
|
||||||
"mon": time.Monday, "monday": time.Monday,
|
"mon": time.Monday, "monday": time.Monday,
|
||||||
@@ -41,16 +206,52 @@ func ParseDate(s string) (time.Time, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if targetWeekday, ok := weekdays[s]; ok {
|
if targetWeekday, ok := weekdays[s]; ok {
|
||||||
return nextWeekday(now, targetWeekday), nil
|
return p.nextWeekday(targetWeekday), true
|
||||||
}
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
return time.Time{}, fmt.Errorf("unable to parse date: %s", s)
|
// parseMonthName handles month names (jan, january, feb, february, etc.)
|
||||||
|
func (p *DateParser) parseMonthName(s string) (time.Time, bool) {
|
||||||
|
// Placeholder for Phase 2
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDayMonth handles day+month formats (21jan, Jan21, etc.)
|
||||||
|
func (p *DateParser) parseDayMonth(s string) (time.Time, bool) {
|
||||||
|
// Placeholder for Phase 2
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePeriodBoundary handles period boundary keywords (sod, eod, sow, eow, etc.)
|
||||||
|
func (p *DateParser) parsePeriodBoundary(s string) (time.Time, bool) {
|
||||||
|
// Placeholder for Phase 2
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSpecialKeyword handles special keywords (later, someday)
|
||||||
|
func (p *DateParser) parseSpecialKeyword(s string) (time.Time, bool) {
|
||||||
|
// Placeholder for Phase 2
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDurationAsDate handles duration as date offset (2d, 3w, etc.)
|
||||||
|
func (p *DateParser) parseDurationAsDate(s string) (time.Time, bool) {
|
||||||
|
// Placeholder for Phase 2
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// nextWeekday returns the next occurrence of the target weekday
|
||||||
// Smart logic: if today is Thursday and target is Sunday, returns this Sunday
|
// Smart logic: if today is Thursday and target is Sunday, returns this Sunday
|
||||||
// If today is Sunday and target is Sunday, returns next Sunday
|
// If today is Sunday and target is Sunday, returns next Sunday
|
||||||
func nextWeekday(from time.Time, target time.Weekday) time.Time {
|
func (p *DateParser) nextWeekday(target time.Weekday) time.Time {
|
||||||
|
from := p.base
|
||||||
// Calculate days until target
|
// Calculate days until target
|
||||||
daysUntil := int(target - from.Weekday())
|
daysUntil := int(target - from.Weekday())
|
||||||
|
|
||||||
@@ -61,3 +262,9 @@ func nextWeekday(from time.Time, target time.Weekday) time.Time {
|
|||||||
next := from.AddDate(0, 0, daysUntil)
|
next := from.AddDate(0, 0, daysUntil)
|
||||||
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
|
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseDate is the public API that uses default settings
|
||||||
|
func ParseDate(s string) (time.Time, error) {
|
||||||
|
parser := NewDefaultDateParser()
|
||||||
|
return parser.ParseDate(s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,7 +199,8 @@ func TestParseDateWeekday(t *testing.T) {
|
|||||||
func TestNextWeekday(t *testing.T) {
|
func TestNextWeekday(t *testing.T) {
|
||||||
// Test case: Thursday -> next Sunday should be this Sunday
|
// Test case: Thursday -> next Sunday should be this Sunday
|
||||||
thursday := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // Jan 1, 2026 is a Thursday
|
thursday := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // Jan 1, 2026 is a Thursday
|
||||||
nextSun := nextWeekday(thursday, time.Sunday)
|
parser := NewDateParser(thursday, time.Monday)
|
||||||
|
nextSun := parser.nextWeekday(time.Sunday)
|
||||||
|
|
||||||
if nextSun.Weekday() != time.Sunday {
|
if nextSun.Weekday() != time.Sunday {
|
||||||
t.Error("Should return Sunday")
|
t.Error("Should return Sunday")
|
||||||
@@ -214,7 +215,8 @@ func TestNextWeekday(t *testing.T) {
|
|||||||
|
|
||||||
// Test case: Sunday -> next Sunday should be 7 days later
|
// Test case: Sunday -> next Sunday should be 7 days later
|
||||||
sunday := time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) // Jan 4, 2026 is a Sunday
|
sunday := time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) // Jan 4, 2026 is a Sunday
|
||||||
nextSun2 := nextWeekday(sunday, time.Sunday)
|
parser2 := NewDateParser(sunday, time.Monday)
|
||||||
|
nextSun2 := parser2.nextWeekday(time.Sunday)
|
||||||
|
|
||||||
expectedDays = 7
|
expectedDays = 7
|
||||||
actualDays = int(nextSun2.Sub(sunday).Hours() / 24)
|
actualDays = int(nextSun2.Sub(sunday).Hours() / 24)
|
||||||
|
|||||||
Reference in New Issue
Block a user