This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""Pure lunar-calendar conversion helpers for countdowns.
|
||||
|
||||
Lunar months use lunar-python's convention: 1..12 are regular months and
|
||||
-1..-12 are leap months. A missing leap month or invalid lunar day returns None.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from lunar_python import Lunar, Solar
|
||||
|
||||
|
||||
def lunar_to_solar_safe(year: int, month: int, day: int) -> date | None:
|
||||
"""Convert a lunar date without allowing library exceptions to escape."""
|
||||
try:
|
||||
solar = Lunar.fromYmd(year, month, day).getSolar()
|
||||
result = date(solar.getYear(), solar.getMonth(), solar.getDay())
|
||||
lunar = solar.getLunar()
|
||||
if (lunar.getYear(), lunar.getMonth(), lunar.getDay()) != (year, month, day):
|
||||
return None
|
||||
return result
|
||||
except Exception: # noqa: BLE001 - library raises plain Exception for invalid leap months.
|
||||
return None
|
||||
|
||||
|
||||
def _lunar_date(value: date):
|
||||
return Solar.fromYmd(value.year, value.month, value.day).getLunar()
|
||||
|
||||
|
||||
def solar_to_lunar_parts(value: date) -> tuple[int, int, int]:
|
||||
lunar = _lunar_date(value)
|
||||
return lunar.getYear(), lunar.getMonth(), lunar.getDay()
|
||||
|
||||
|
||||
def solar_to_lunar_text(value: date) -> str:
|
||||
lunar = _lunar_date(value)
|
||||
return f"农历{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}"
|
||||
|
||||
|
||||
def lunar_label_with_year(value: date) -> str:
|
||||
lunar = _lunar_date(value)
|
||||
return f"农历{lunar.getYearInChinese()}年{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}"
|
||||
|
||||
|
||||
def next_lunar_occurrence(
|
||||
month: int,
|
||||
day: int,
|
||||
ignore_year: bool,
|
||||
repeat_rule: str,
|
||||
today: date,
|
||||
) -> date | None:
|
||||
"""Find the next matching lunar month/day, including sparse leap months."""
|
||||
del ignore_year, repeat_rule # Both make lunar month/day recur by lunar year.
|
||||
# Leap months can be separated by more than a decade. The library supports a
|
||||
# bounded year range, so search far enough for all practical countdowns.
|
||||
for year in range(today.year - 1, today.year + 101):
|
||||
candidate = lunar_to_solar_safe(year, month, day)
|
||||
if candidate is not None and candidate >= today:
|
||||
return candidate
|
||||
return None
|
||||
@@ -135,6 +135,10 @@ class Countdown(Base):
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
event_date: Mapped[date] = mapped_column(Date)
|
||||
calendar_mode: Mapped[str] = mapped_column(String(8), default="solar")
|
||||
lunar_month: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lunar_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
ignore_year: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), default="countdown")
|
||||
repeat_rule: Mapped[str] = mapped_column(String(16), default="none")
|
||||
icon: Mapped[str] = mapped_column(String(32), default="📅")
|
||||
|
||||
+91
-3
@@ -16,6 +16,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from .auth import current_user
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .lunar_support import (
|
||||
lunar_label_with_year,
|
||||
lunar_to_solar_safe,
|
||||
next_lunar_occurrence,
|
||||
solar_to_lunar_parts,
|
||||
solar_to_lunar_text,
|
||||
)
|
||||
from .models import (
|
||||
Attachment,
|
||||
AuditLog,
|
||||
@@ -290,15 +297,41 @@ def countdown_status(display_date: date, today: date) -> tuple[int, str]:
|
||||
class CountdownInput(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
event_date: date
|
||||
calendar_mode: str = Field("solar", pattern="^(solar|lunar)$")
|
||||
lunar_month: int | None = None
|
||||
lunar_day: int | None = None
|
||||
ignore_year: bool = False
|
||||
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
|
||||
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
|
||||
icon: str = Field("📅", min_length=1, max_length=32)
|
||||
pinned: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def calendar_fields_valid(self):
|
||||
if self.calendar_mode == "solar":
|
||||
if self.lunar_month is not None or self.lunar_day is not None:
|
||||
raise ValueError("solar countdown cannot include lunar fields")
|
||||
return self
|
||||
if self.lunar_month is None or self.lunar_day is None:
|
||||
raise ValueError("lunar_month and lunar_day are required")
|
||||
if self.lunar_month == 0 or not -12 <= self.lunar_month <= 12:
|
||||
raise ValueError("lunar_month must be 1..12 or -1..-12 for leap months")
|
||||
if not 1 <= self.lunar_day <= 30:
|
||||
raise ValueError("lunar_day must be 1..30")
|
||||
converted = lunar_to_solar_safe(self.event_date.year, self.lunar_month, self.lunar_day)
|
||||
if converted is None:
|
||||
raise ValueError("lunar date does not exist in the selected year")
|
||||
self.event_date = converted
|
||||
return self
|
||||
|
||||
|
||||
class CountdownUpdate(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=200)
|
||||
event_date: date | None = None
|
||||
calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$")
|
||||
lunar_month: int | None = None
|
||||
lunar_day: int | None = None
|
||||
ignore_year: bool | None = None
|
||||
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
|
||||
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
|
||||
icon: str | None = Field(None, min_length=1, max_length=32)
|
||||
@@ -308,18 +341,43 @@ class CountdownUpdate(BaseModel):
|
||||
for field in self.model_fields_set:
|
||||
if getattr(self, field) is None:
|
||||
raise ValueError(f"{field} cannot be null")
|
||||
if self.lunar_month is not None and (self.lunar_month == 0 or not -12 <= self.lunar_month <= 12):
|
||||
raise ValueError("lunar_month must be 1..12 or -1..-12 for leap months")
|
||||
if self.lunar_day is not None and not 1 <= self.lunar_day <= 30:
|
||||
raise ValueError("lunar_day must be 1..30")
|
||||
return self
|
||||
|
||||
|
||||
def countdown_dict(row: Countdown, today: date | None = None):
|
||||
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||
display_date = countdown_occurrence(row.event_date, row.repeat_rule, today)
|
||||
if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule != "none"):
|
||||
display_date = next_lunar_occurrence(
|
||||
row.lunar_month, row.lunar_day, row.ignore_year, row.repeat_rule, today
|
||||
) or row.event_date
|
||||
else:
|
||||
repeat_rule = "yearly" if row.ignore_year else row.repeat_rule
|
||||
display_date = countdown_occurrence(row.event_date, repeat_rule, today)
|
||||
days, day_text = countdown_status(display_date, today)
|
||||
lunar_year = None
|
||||
lunar_text = None
|
||||
if row.calendar_mode == "lunar":
|
||||
lunar_year, _, _ = solar_to_lunar_parts(row.event_date)
|
||||
lunar_text = (
|
||||
solar_to_lunar_text(display_date)
|
||||
if row.ignore_year or row.repeat_rule != "none"
|
||||
else lunar_label_with_year(row.event_date)
|
||||
)
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"event_date": row.event_date,
|
||||
"display_date": display_date,
|
||||
"calendar_mode": row.calendar_mode,
|
||||
"lunar_year": lunar_year,
|
||||
"lunar_month": row.lunar_month,
|
||||
"lunar_day": row.lunar_day,
|
||||
"ignore_year": row.ignore_year,
|
||||
"lunar_text": lunar_text,
|
||||
"kind": row.kind,
|
||||
"repeat_rule": row.repeat_rule,
|
||||
"icon": row.icon,
|
||||
@@ -367,7 +425,30 @@ async def list_countdowns(archived: bool = False, user: User = Depends(current_u
|
||||
@router.patch("/countdowns/{countdown_id}")
|
||||
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
values = payload.model_dump(exclude_unset=True)
|
||||
combined = {
|
||||
"event_date": values.get("event_date", row.event_date),
|
||||
"calendar_mode": values.get("calendar_mode", row.calendar_mode),
|
||||
"lunar_month": values.get("lunar_month", row.lunar_month),
|
||||
"lunar_day": values.get("lunar_day", row.lunar_day),
|
||||
"ignore_year": values.get("ignore_year", row.ignore_year),
|
||||
}
|
||||
if combined["calendar_mode"] == "solar":
|
||||
if combined["lunar_month"] is not None or combined["lunar_day"] is not None:
|
||||
if "calendar_mode" not in values:
|
||||
raise HTTPException(422, "公历倒数日不能设置农历日期")
|
||||
combined["lunar_month"] = combined["lunar_day"] = None
|
||||
else:
|
||||
if combined["lunar_month"] is None or combined["lunar_day"] is None:
|
||||
raise HTTPException(422, "农历倒数日需要月份和日期")
|
||||
converted = lunar_to_solar_safe(
|
||||
combined["event_date"].year, combined["lunar_month"], combined["lunar_day"]
|
||||
)
|
||||
if converted is None:
|
||||
raise HTTPException(422, "所选年份不存在该农历日期")
|
||||
combined["event_date"] = converted
|
||||
values.update(combined)
|
||||
for key, value in values.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = utcnow()
|
||||
audit(db, user.id, "update", "countdown", row.id)
|
||||
@@ -672,7 +753,7 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
|
||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
|
||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
}
|
||||
|
||||
|
||||
@@ -766,11 +847,18 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
item = CountdownInput(
|
||||
title=raw["title"],
|
||||
event_date=date.fromisoformat(raw["event_date"]),
|
||||
calendar_mode=raw.get("calendar_mode", "solar"),
|
||||
lunar_month=raw.get("lunar_month"),
|
||||
lunar_day=raw.get("lunar_day"),
|
||||
ignore_year=bool(raw.get("ignore_year", False)),
|
||||
kind=raw.get("kind", "countdown"),
|
||||
repeat_rule=raw.get("repeat_rule", "none"),
|
||||
icon=raw.get("icon", "📅"),
|
||||
pinned=bool(raw.get("pinned", False)) and not has_pinned_countdown,
|
||||
)
|
||||
# Backups store the canonical solar anchor; validation above converts
|
||||
# lunar input again, so preserve the exact exported anchor on restore.
|
||||
item.event_date = date.fromisoformat(raw["event_date"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(422, "无效的倒数日备份数据") from exc
|
||||
row = Countdown(
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-2NrQsU0j.js"></script>
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-Ds2Y4RQ7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DQ5ZvkC6.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
Reference in New Issue
Block a user