refactor: migrate backend dt
This commit is contained in:
444
backend/dt/dt.go
Normal file
444
backend/dt/dt.go
Normal file
@@ -0,0 +1,444 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user