206 lines
7.3 KiB
Go
206 lines
7.3 KiB
Go
// Package lunar 把公历与农历互转,并提供「按农历推进」的重复规则计算。
|
||
//
|
||
// 为什么要单独一层而不直接用 lunar-go:
|
||
//
|
||
// 1. **lunar-go 在非法日期上 panic 而不是返回 error**。
|
||
// `NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar
|
||
// year 2027 month 9") —— 农历月是 29 或 30 天不定,「每月农历三十」
|
||
// 这条规则必然会撞上 29 天的月份。调度器里一次 panic 就让那一轮所有
|
||
// 提醒全部落空(虽然有 recover 兜底,但结果是那一条提醒永久卡住)。
|
||
//
|
||
// 2. **闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
|
||
// 2025 有闰六月、2028 有闰五月,而 2026/2027 没有 —— 「每年农历某月
|
||
// 某日」跨过闰月年份时必须决定落在哪个月,这个决策不该散落在 repo 里。
|
||
//
|
||
// 3. **按农历推进不能靠加固定天数**。农历月 29~30 天、农历年 353~385 天
|
||
// (闰年多一个月)。用 AddDate 近似会越推越偏,一年下来能差半个月。
|
||
package lunar
|
||
|
||
import (
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/6tail/lunar-go/calendar"
|
||
)
|
||
|
||
// Date 是一个农历日期。
|
||
//
|
||
// Month 为负数表示闰月(-6 = 闰六月),与 lunar-go 的约定一致 ——
|
||
// 刻意沿用而不另造一个 IsLeap bool:两种表示混用时转换处极易写反,
|
||
// 而负数在数值比较里天然排在正数前面(闰六月在六月之后,需要注意这一点,
|
||
// 见 monthsInYear 的排序)。
|
||
type Date struct {
|
||
Year int
|
||
Month int // 负数 = 闰月
|
||
Day int
|
||
}
|
||
|
||
// FromSolar 把公历时刻转成农历日期。
|
||
//
|
||
// 只取年月日,时分秒由调用方保留 —— 农历只定义到「日」,
|
||
// 「农历七月十五早上九点」的「九点」是公历时钟的概念。
|
||
func FromSolar(t time.Time) Date {
|
||
s := calendar.NewSolarFromYmd(t.Year(), int(t.Month()), t.Day())
|
||
l := s.GetLunar()
|
||
return Date{Year: l.GetYear(), Month: l.GetMonth(), Day: l.GetDay()}
|
||
}
|
||
|
||
// ToSolar 把农历日期转回公历,并带上给定的时分秒。
|
||
//
|
||
// **日期会被夹到该农历月的实际天数内**:请求农历三十而该月只有 29 天时
|
||
// 返回廿九,而不是 panic 也不是滚到下个月的初一。
|
||
//
|
||
// 夹而不滚的理由:「每月农历三十」的语义是「月末那天」,滚到下月初一会让
|
||
// 提醒出现在完全错误的日子(且与前一次提醒只隔一天)。
|
||
//
|
||
// 返回的 clamped 说明是否发生了夹取 —— 调用方据此决定是否要在 UI 上提示。
|
||
func (d Date) ToSolar(loc *time.Location, hour, min, sec, nsec int) (t time.Time, clamped bool, err error) {
|
||
days, err := DaysInMonth(d.Year, d.Month)
|
||
if err != nil {
|
||
return time.Time{}, false, err
|
||
}
|
||
day := d.Day
|
||
if day > days {
|
||
day = days
|
||
clamped = true
|
||
}
|
||
if day < 1 {
|
||
return time.Time{}, false, fmt.Errorf("农历日 %d 非法", d.Day)
|
||
}
|
||
|
||
// lunar-go 在非法输入上 panic,这里兜住转成 error:
|
||
// 上面已经夹过日期,理论上不会触发,但闰月不存在之类的组合仍可能进来。
|
||
var solar *calendar.Solar
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
err = fmt.Errorf("农历 %d-%d-%d 无法转公历: %v", d.Year, d.Month, day, r)
|
||
}
|
||
}()
|
||
solar = calendar.NewLunarFromYmd(d.Year, d.Month, day).GetSolar()
|
||
}()
|
||
if err != nil {
|
||
return time.Time{}, false, err
|
||
}
|
||
if solar == nil {
|
||
return time.Time{}, false, fmt.Errorf("农历 %d-%d-%d 转公历得到空值", d.Year, d.Month, day)
|
||
}
|
||
|
||
return time.Date(solar.GetYear(), time.Month(solar.GetMonth()), solar.GetDay(),
|
||
hour, min, sec, nsec, loc), clamped, nil
|
||
}
|
||
|
||
// DaysInMonth 返回某个农历月有多少天(29 或 30)。
|
||
//
|
||
// Month 为负数时查闰月。该年没有这个闰月则返回错误 ——
|
||
// 这不是异常情况:「每年农历闰六月十五」这条规则在没有闰六月的年份
|
||
// 本来就无法落地,调用方需要据此跳过而不是猜一个日子。
|
||
func DaysInMonth(year, month int) (int, error) {
|
||
var days int
|
||
var found bool
|
||
// LunarYear.GetMonths() 是 *list.List,元素为 *LunarMonth。
|
||
// 一个 LunarYear 对象里会带上跨年边界的月份,因此必须同时比对 year。
|
||
ly := calendar.NewLunarYear(year)
|
||
for e := ly.GetMonths().Front(); e != nil; e = e.Next() {
|
||
lm, ok := e.Value.(*calendar.LunarMonth)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if lm.GetYear() == year && lm.GetMonth() == month {
|
||
days = lm.GetDayCount()
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
if month < 0 {
|
||
return 0, fmt.Errorf("农历 %d 年没有闰 %d 月", year, -month)
|
||
}
|
||
return 0, fmt.Errorf("农历 %d 年没有 %d 月", year, month)
|
||
}
|
||
return days, nil
|
||
}
|
||
|
||
// LeapMonth 返回某农历年的闰月(0 = 无闰月)。
|
||
func LeapMonth(year int) int {
|
||
return calendar.NewLunarYear(year).GetLeapMonth()
|
||
}
|
||
|
||
// AddMonths 在农历上推进若干个月。
|
||
//
|
||
// 逐月走而不是「月份数 + n 再取模」:中间可能夹着闰月,
|
||
// 而闰月是否存在取决于年份,没有闭式公式。
|
||
//
|
||
// 闰月的处理:**推进时跳过闰月**。从六月推一个月得七月,不是闰六月。
|
||
// 理由是「每月十五」这类规则的用户期望是一年 12 次,
|
||
// 把闰月算进去会让闰年多出一次提醒 —— 那是农历年的性质,不是提醒的性质。
|
||
// 想要闰月本身的提醒应该用 lunar_yearly 指定 -6 月。
|
||
func (d Date) AddMonths(n int) Date {
|
||
y, m := d.Year, d.Month
|
||
// 从闰月出发时先归到对应的正月份:闰六月 +1 → 七月
|
||
if m < 0 {
|
||
m = -m
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
m++
|
||
if m > 12 {
|
||
m = 1
|
||
y++
|
||
}
|
||
}
|
||
return Date{Year: y, Month: m, Day: d.Day}
|
||
}
|
||
|
||
// AddYears 在农历上推进若干年,月份与日期保持不变。
|
||
//
|
||
// 从闰月出发时(Month < 0)目标年没有同一个闰月,退回对应的正月份 ——
|
||
// 「去年闰六月十五」在今年最接近的对应日就是六月十五。
|
||
// 直接放弃(不再提醒)更糟:那是静默地让重复事件消失。
|
||
func (d Date) AddYears(n int) Date {
|
||
y := d.Year + n
|
||
m := d.Month
|
||
if m < 0 && LeapMonth(y) != -m {
|
||
m = -m
|
||
}
|
||
return Date{Year: y, Month: m, Day: d.Day}
|
||
}
|
||
|
||
// String 给出「二〇二六年七月廿二」这样的中文农历表示。
|
||
//
|
||
// UI 上必须显示它:农历事件的公历日期每年都在变,
|
||
// 只显示公历会让人无法确认「这条规则是不是我想的那个农历日子」。
|
||
func (d Date) String() string {
|
||
days, err := DaysInMonth(d.Year, d.Month)
|
||
if err != nil {
|
||
return fmt.Sprintf("农历 %d 年%d月%d日(无效)", d.Year, d.Month, d.Day)
|
||
}
|
||
day := d.Day
|
||
if day > days {
|
||
day = days
|
||
}
|
||
var out string
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
out = fmt.Sprintf("农历 %d-%d-%d", d.Year, d.Month, d.Day)
|
||
}
|
||
}()
|
||
out = calendar.NewLunarFromYmd(d.Year, d.Month, day).String()
|
||
}()
|
||
return out
|
||
}
|
||
|
||
// FormatSolar 把公历时刻渲染成「2026-09-03(农历七月廿二)」。
|
||
//
|
||
// 给提醒正文与 UI 用:两种历都写出来,人才能确认规则没被理解错。
|
||
func FormatSolar(t time.Time) string {
|
||
d := FromSolar(t)
|
||
full := d.String()
|
||
// 去掉年份部分(「二〇二六年」共 4 个中文字符 + 「年」),只留月日 ——
|
||
// 公历年份已经在前面写了,重复一遍反而更难读。
|
||
if r := []rune(full); len(r) > 5 && r[4] == '年' {
|
||
full = string(r[5:])
|
||
}
|
||
return fmt.Sprintf("%s(农历%s)", t.Format("2006-01-02"), full)
|
||
}
|