From 7e335c51c64dd548e6df9340f07e6c1c4356ad4d Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Wed, 15 Jul 2026 20:33:14 +0800 Subject: [PATCH] refactor: migrate backend dt --- backend/dt/dt.go | 444 +++++++++++++++++++++++++++++++++++++++++ backend/utils/utils.go | 4 +- 2 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 backend/dt/dt.go diff --git a/backend/dt/dt.go b/backend/dt/dt.go new file mode 100644 index 0000000..6ed4068 --- /dev/null +++ b/backend/dt/dt.go @@ -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 +} diff --git a/backend/utils/utils.go b/backend/utils/utils.go index b917d2e..36e5612 100644 --- a/backend/utils/utils.go +++ b/backend/utils/utils.go @@ -80,7 +80,7 @@ func Str2Bool(s string) bool { // GCD returns the greatest common divisor of a and b via the Euclidean // algorithm. -func GCD(a, b int) int { +func GCD(a, b int64) int64 { for b != 0 { a, b = b, a%b } @@ -88,6 +88,6 @@ func GCD(a, b int) int { } // LCM returns the least common multiple of a and b. -func LCM(a, b int) int { +func LCM(a, b int64) int64 { return (a * b) / GCD(a, b) }