445 lines
12 KiB
Go
445 lines
12 KiB
Go
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
|
|
}
|