You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

117 lines
4.2 KiB
Python

# backend/app/connectors/calendar/recurrence.py
# 반복 일정 표현을 iCalendar RRULE 문자열로 통일한다.
# - Google: recurrence 는 본래 ["RRULE:..."] 리스트 → 그대로 변환.
# - Outlook(Graph): recurrence 는 pattern/range 객체 → 양방향 변환.
# UI 가 제공하는 흔한 경우(매일/매주/평일/매월/매년 + 간격·횟수·종료일)를 지원한다.
_DAY_TO_GRAPH = {
"MO": "monday",
"TU": "tuesday",
"WE": "wednesday",
"TH": "thursday",
"FR": "friday",
"SA": "saturday",
"SU": "sunday",
}
_GRAPH_TO_DAY = {v: k for k, v in _DAY_TO_GRAPH.items()}
_WD_INDEX = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
def parse_rrule(rrule: str) -> dict:
"""'FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2'{FREQ:.., BYDAY:[..], INTERVAL:'2'}."""
out: dict = {}
for part in (rrule or "").replace("RRULE:", "").split(";"):
if "=" not in part:
continue
k, v = part.split("=", 1)
k = k.strip().upper()
if k == "BYDAY":
out[k] = [d.strip().upper() for d in v.split(",") if d.strip()]
else:
out[k] = v.strip()
return out
def rrule_to_google(rrule: str) -> list[str]:
"""Google Calendar recurrence 필드(리스트)."""
if not rrule:
return []
body = rrule if rrule.upper().startswith("RRULE:") else f"RRULE:{rrule}"
return [body]
def rrule_to_graph(rrule: str, date_iso: str) -> dict | None:
"""RRULE + 시작일(YYYY-MM-DD) → Microsoft Graph recurrence 객체."""
r = parse_rrule(rrule)
freq = r.get("FREQ", "").upper()
if not freq:
return None
interval = int(r.get("INTERVAL", "1") or 1)
pattern: dict = {"interval": interval}
if freq == "DAILY":
pattern["type"] = "daily"
elif freq == "WEEKLY":
pattern["type"] = "weekly"
days = r.get("BYDAY") or []
if not days and len(date_iso) >= 10:
from datetime import date
days = [_WD_INDEX[date.fromisoformat(date_iso[:10]).weekday()]]
pattern["daysOfWeek"] = [_DAY_TO_GRAPH[d] for d in days if d in _DAY_TO_GRAPH]
elif freq == "MONTHLY":
pattern["type"] = "absoluteMonthly"
pattern["dayOfMonth"] = int(date_iso[8:10]) if len(date_iso) >= 10 else 1
elif freq == "YEARLY":
pattern["type"] = "absoluteYearly"
pattern["month"] = int(date_iso[5:7]) if len(date_iso) >= 10 else 1
pattern["dayOfMonth"] = int(date_iso[8:10]) if len(date_iso) >= 10 else 1
else:
return None
rng: dict = {"type": "noEnd", "startDate": date_iso[:10]}
if r.get("COUNT"):
rng = {
"type": "numbered",
"startDate": date_iso[:10],
"numberOfOccurrences": int(r["COUNT"]),
}
elif r.get("UNTIL"):
until = r["UNTIL"][:8] # YYYYMMDD
if len(until) == 8:
rng = {
"type": "endDate",
"startDate": date_iso[:10],
"endDate": f"{until[:4]}-{until[4:6]}-{until[6:8]}",
}
return {"pattern": pattern, "range": rng}
def graph_to_rrule(recurrence: dict | None) -> str:
"""Graph recurrence 객체 → RRULE 문자열(표시·재동기화용, 베스트 에포트)."""
if not recurrence:
return ""
pat = recurrence.get("pattern") or {}
rng = recurrence.get("range") or {}
t = (pat.get("type") or "").lower()
freq = {
"daily": "DAILY",
"weekly": "WEEKLY",
"absolutemonthly": "MONTHLY",
"relativemonthly": "MONTHLY",
"absoluteyearly": "YEARLY",
"relativeyearly": "YEARLY",
}.get(t)
if not freq:
return ""
parts = [f"FREQ={freq}"]
if int(pat.get("interval", 1) or 1) > 1:
parts.append(f"INTERVAL={int(pat['interval'])}")
if freq == "WEEKLY" and pat.get("daysOfWeek"):
days = [_GRAPH_TO_DAY[d] for d in pat["daysOfWeek"] if d in _GRAPH_TO_DAY]
if days:
parts.append("BYDAY=" + ",".join(days))
if (rng.get("type") or "") == "numbered" and rng.get("numberOfOccurrences"):
parts.append(f"COUNT={int(rng['numberOfOccurrences'])}")
elif (rng.get("type") or "") == "endDate" and rng.get("endDate"):
parts.append("UNTIL=" + str(rng["endDate"]).replace("-", "") + "T000000Z")
return ";".join(parts)