package dt import ( "errors" "fmt" "regexp" "strconv" "strings" "time" "github.com/yyc12345/coconut-leaf/backend/utils" ) // All time values in this file use minute-granularity timestamps (UNIX seconds // / 60), mirroring the legacy dt.py. tzoffset is also in minutes. var monthDayCount = [12]int64{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} var ( minDatetime = time.Date(1950, time.January, 1, 0, 0, 0, 0, time.UTC) maxDatetime = time.Date(2200, time.January, 1, 0, 0, 0, 0, time.UTC) minTimestamp = minDatetime.Unix() / 60 maxTimestamp = maxDatetime.Unix() / 60 maxDatetimeYear = int64(maxDatetime.Year()) ) const ( day1Span = int64(60 * 24) day7Span = int64(7) * day1Span ) var ( loopYearRule = regexp.MustCompile(`^Y([SR]{1})([1-9][0-9]*)$`) loopMonthRule = regexp.MustCompile(`^M([SR]{1})([ABCD])([1-9][0-9]*)$`) loopWeekRule = regexp.MustCompile(`^W([TF]{7})([1-9][0-9]*)$`) loopDayRule = regexp.MustCompile(`^D([1-9][0-9]*)$`) loopStopInfinity = regexp.MustCompile(`^F$`) loopStopDatetime = regexp.MustCompile(`^D([1-9][0-9]*|0)$`) loopStopTimes = regexp.MustCompile(`^T([1-9][0-9]*)$`) ) type loopHandler func(sub []string, starttime, loopTimes, tzoffset int64) (int64, error) var loopRules = []struct { re *regexp.Regexp handler loopHandler }{ {loopYearRule, loopHandleYear}, {loopMonthRule, loopHandleMonth}, {loopWeekRule, loopHandleWeek}, {loopDayRule, loopHandleDay}, } // ResolveLoopStr parses a loop-rule string of the form "[rules]-[stop]" and // returns the loop end timestamp (minute granularity), given the start time and // timezone offset (both in minutes). Mirrors the legacy dt.ResolveLoopStr. func ResolveLoopStr(strl string, starttime, tzoffset int64) (int64, error) { // check no loop if strl == "" { return starttime, nil } parts := strings.Split(strl, "-") if len(parts) != 2 { return 0, errors.New("invalid loop rule: expected exactly one '-' separator") } rulesStr, stopStr := parts[0], parts[1] // try compute from loopStop if loopStopInfinity.MatchString(stopStr) { return maxTimestamp, nil } if m := loopStopDatetime.FindStringSubmatch(stopStr); m != nil { ts, err := strconv.ParseInt(m[1], 10, 64) if err != nil { return 0, fmt.Errorf("invalid loop datetime stop: %w", err) } return ts, nil } var loopTimes int64 if m := loopStopTimes.FindStringSubmatch(stopStr); m != nil { t, err := strconv.ParseInt(m[1], 10, 64) if err != nil { return 0, fmt.Errorf("invalid loop times stop: %w", err) } loopTimes = t } else { return 0, errors.New("invalid loop stop rules") } for _, rule := range loopRules { if m := rule.re.FindStringSubmatch(rulesStr); m != nil { return rule.handler(m, starttime, loopTimes, tzoffset) } } return 0, errors.New("invalid loop rules") } // clientDateComponents extracts the (year, month, day) wall-clock components of // starttime (minute timestamp) under the fixed offset tzoffset (minutes). This // mirrors python's datetime.fromtimestamp(starttime*60, UTCTimezone(tzoffset)). // Note time.FixedZone takes seconds, hence tzoffset*60. func clientDateComponents(starttime int64, tzoffset int64) (int64, int64, int64) { loc := time.FixedZone("offset", int(tzoffset*60)) t := time.Unix(starttime*60, 0).In(loc) return int64(t.Year()), int64(t.Month()), int64(t.Day()) } func loopHandleYear(sub []string, starttime, times, tzoffset int64) (int64, error) { clientYear, clientMonth, clientDay := clientDateComponents(starttime, tzoffset) isStrict := sub[1] == "S" yearSpan, err := strconv.ParseInt(sub[2], 10, 64) if err != nil { return 0, fmt.Errorf("invalid year span: %w", err) } times-- newYear, newMonth, newDay := clientYear, clientMonth, clientDay if clientMonth == 2 && clientDay == 29 { if isStrict { realSpan := utils.LCM(yearSpan, 4) valCache := starttime for valCache < maxTimestamp && times > 0 { newYear += realSpan if !isLeapYear(newYear) { continue } valCache = starttime + day1Span*(daysCount(newYear, newMonth, newDay)-daysCount(clientYear, clientMonth, clientDay)) times-- } } else { newYear += times * yearSpan if !isLeapYear(newYear) { newDay = 28 } } } else { // if times == 1, no extra datetime need to be added newYear += times * yearSpan } val := starttime + day1Span*(daysCount(newYear, newMonth, newDay)-daysCount(clientYear, clientMonth, clientDay)) if val < maxTimestamp { return val, nil } else { return maxTimestamp, nil } } func loopHandleMonth(sub []string, starttime, times, tzoffset int64) (int64, error) { isStrict := sub[1] == "S" loopType := sub[2] monthSpan, err := strconv.ParseInt(sub[3], 10, 64) if err != nil { return 0, fmt.Errorf("invalid month span: %w", err) } // we should get original data in each method times-- clientYear, clientMonth, clientDay := clientDateComponents(starttime, tzoffset) newYear, newMonth, newDay := clientYear, clientMonth, clientDay // data struct // ds = // (dayForwards || dayBackwards || weeksForward, dayOfWeek || weeksBackwards, dayOfWeek) // ( A || B || C || D ) ds := getDayInMonth(clientYear, clientMonth, clientDay) advanceMonth := func() { newMonth += monthSpan if newMonth > 12 { newYear += (newMonth - 1) / 12 newMonth = ((newMonth - 1) % 12) + 1 } } if isStrict { switch loopType { case "A": for times > 0 { advanceMonth() if newYear > maxDatetimeYear { break } maxDays := monthDayCount[newMonth-1] if newMonth == 2 && isLeapYear(newYear) { maxDays++ } if ds.daysForward <= maxDays { times-- } } case "B": for times > 0 { advanceMonth() if newYear > maxDatetimeYear { break } maxDays := monthDayCount[newMonth-1] if newMonth == 2 && isLeapYear(newYear) { maxDays++ } if ds.daysBackward <= maxDays { times-- } } case "C": for times > 0 { advanceMonth() if newYear > maxDatetimeYear { break } ms := getMonthWeekStatistics(newYear, newMonth) if ds.weeksForward <= ms[ds.weeksForwardDayOfWeek] { times-- } } case "D": for times > 0 { advanceMonth() if newYear > maxDatetimeYear { break } ms := getMonthWeekStatistics(newYear, newMonth) if ds.weeksBackward <= ms[ds.weeksBackwardDayOfWeek] { times-- } } } } else { newMonth += times * monthSpan newYear += (newMonth - 1) / 12 newMonth = ((newMonth - 1) % 12) + 1 } // all method need calc newDay and it should be the last day of current selected month // so calc it in there newDay = monthDayCount[newMonth-1] if newMonth == 2 && isLeapYear(newYear) { newDay++ } val := starttime + day1Span*(daysCount(newYear, newMonth, newDay)-daysCount(clientYear, clientMonth, clientDay)) if val < maxTimestamp { return val, nil } else { return maxTimestamp, nil } } func loopHandleWeek(sub []string, starttime, times, tzoffset int64) (int64, error) { weekStr := sub[1] var weekOccupied [7]bool var weekEventCount int64 for i := range 7 { weekOccupied[i] = weekStr[i] == 'T' if weekOccupied[i] { weekEventCount++ } } if weekEventCount == 0 { return 0, errors.New("invalid week format") } weekSpan, err := strconv.ParseInt(sub[2], 10, 64) if err != nil { return 0, fmt.Errorf("invalid week span: %w", err) } // Use the ported dayOfWeek (Monday=0) instead of Go's time.Weekday (Sunday=0) // so the week rule's Monday-first indexing stays consistent. cy, cm, cd := clientDateComponents(starttime, tzoffset) nowDayOfWeek := dayOfWeek(cy, cm, cd) if !weekOccupied[nowDayOfWeek] { // if first event is not suit for week loop rules, minus one more event to suit it. times-- } fullWeek := times / weekEventCount remainEvent := times % weekEventCount val := starttime + day7Span*fullWeek*weekSpan if val > maxTimestamp { // return now, to reduce calc usage return maxTimestamp, nil } for remainEvent != 0 { val += day1Span if weekOccupied[nowDayOfWeek%7] { remainEvent-- } nowDayOfWeek++ } val-- if val < maxTimestamp { return val, nil } else { return maxTimestamp, nil } } func loopHandleDay(sub []string, starttime, times, tzoffset int64) (int64, error) { span, err := strconv.ParseInt(sub[1], 10, 64) if err != nil { return 0, fmt.Errorf("invalid day span: %w", err) } val := starttime + day1Span*times*span - 1 if val < maxTimestamp { return val, nil } return maxTimestamp, nil } // leapYearCountEx counts leap years following the proleptic Gregorian // 4/100/400 rule. NOTE: the legacy python had a typo in the baseYear branch // (subtracted /400 instead of /100, which cancelled the +/400); it is corrected // here. The fix has no observable effect on current call sites, since baseYear // is always 1 and floor(1/n)=0 for n in {4,100,400}. func leapYearCountEx(endYear int64, includeThis bool, baseYear int64, includeBase bool) int64 { if !includeThis { endYear-- } if includeBase { baseYear-- } endly := endYear / 4 endly -= endYear / 100 endly += endYear / 400 basely := baseYear / 4 basely -= baseYear / 100 basely += baseYear / 400 return endly - basely } func leapYearCount(year int64) int64 { return leapYearCountEx(year, false, 1, true) } func isLeapYear(year int64) bool { isLeap := false if year%4 == 0 { isLeap = true } if year%100 == 0 { isLeap = false } if year%400 == 0 { isLeap = true } return isLeap } func daysCount(year, month, day int64) int64 { ly := leapYearCountEx(year, false, 1, true) days := int64(365) * (year - 1) days += ly for index := int64(1); index < month; index++ { days += monthDayCount[index-1] } if month > 2 && isLeapYear(year) { days++ } days += day - 1 return days } // dayOfWeek returns the day of week with Monday=0 .. Sunday=6 (independent of // Go's time.Weekday convention), derived from the portable daysCount. func dayOfWeek(year, month, day int64) int64 { // As we know, Jan 1, 1900 is Monday. // According to this, we can speculate Jan 1, 0001 also is Monday. return daysCount(year, month, day) % 7 } // dayInMonthInfo holds positional statistics for a day within its month. The // day-of-week values use Monday=0 indexing. type dayInMonthInfo struct { // daysForward is the day count to this day, counting from month head to tail. daysForward int64 // daysBackward is the day count to this day, counting from month tail to head. daysBackward int64 // weeksForward is the count of the week this day is located in, counting from // month head to tail. weeksForward int64 // weeksForwardDayOfWeek is the day-of-week index paired with weeksForward. weeksForwardDayOfWeek int64 // weeksBackward is the count of the week this day is located in, counting from // month tail to head. weeksBackward int64 // weeksBackwardDayOfWeek is the day-of-week index paired with weeksBackward. weeksBackwardDayOfWeek int64 } // getDayInMonth returns the positional statistics of the given date within its // month. func getDayInMonth(year, month, day int64) dayInMonthInfo { days := monthDayCount[month-1] if month == 2 && isLeapYear(year) { days++ } firstDayOfWeek := dayOfWeek(year, month, 1) dow := (firstDayOfWeek + day - 1) % 7 daysForward := day daysBackward := days - day + 1 weeksForward := (daysForward-1)/7 + 1 weeksBackward := (daysBackward-1)/7 + 1 return dayInMonthInfo{ daysForward: daysForward, daysBackward: daysBackward, weeksForward: weeksForward, weeksForwardDayOfWeek: dow, weeksBackward: weeksBackward, weeksBackwardDayOfWeek: dow, } } // getMonthWeekStatistics returns, for each weekday Monday=0..Sunday=6, how many // times that weekday occurs in the given month. func getMonthWeekStatistics(year, month int64) [7]int64 { days := monthDayCount[month-1] if month == 2 && isLeapYear(year) { days++ } firstDayOfWeek := dayOfWeek(year, month, 1) // lastDayOfWeek := (firstDayOfWeek + days - 1) % 7 result := [7]int64{4, 4, 4, 4, 4, 4, 4} remain := days % 7 week := firstDayOfWeek for remain > 0 { result[week%7]++ week++ remain-- } return result }