9973631df0
Extract shared code that was duplicated across functions: - taskJSON struct (MarshalJSON/UnmarshalJSON) to package-level type - scanTask(scanner) helper for GetTask/GetTasks (~70 identical lines) - monthNames map for parseMonthName/parseDayAndMonth - applyNonDateAttribute helper for Apply/ApplyToNew - resolveDisplayID calls replace inline loops in FormatTaskListWithFormat Replace O(n²) bubble sorts with sort.Slice in all four report sort functions (sortByUrgency, NewestReport, NextReport, OldestReport). Remove dead code: formatTimeWithColor (unused, also used time.Now() instead of timeNow()), getCurrentTimestamp (unnecessary wrapper). Remove ~20 comments that restated the next line of code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
478 lines
14 KiB
Go
478 lines
14 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var monthNames = 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,
|
|
}
|
|
|
|
// 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) {
|
|
month, ok := monthNames[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
|
|
}
|
|
|
|
month, ok := monthNames[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)
|
|
}
|