1
0
Files
coconut-leaf/backend/dt/dt.go

445 lines
12 KiB
Go
Raw Normal View History

2026-07-15 20:33:14 +08:00
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
}