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.

281 lines
11 KiB
Python

# backend/app/connectors/calendar/normalize.py — provider → cal_event(phase-8) 정규화 + upsert
import hashlib
import uuid
from sqlmodel import Session, select
from ...models import Calendar, CalEvent, ExternalLink
from ..base import NormalizedRecord, RawRecord
from .recurrence import graph_to_rrule
def _google_attendees(p: dict) -> tuple[list, str]:
"""Google attendees → ([{email,name,status}], 내 응답상태)."""
out, mine = [], ""
for a in p.get("attendees", []) or []:
email = a.get("email", "")
if not email:
continue
status = a.get("responseStatus", "needsAction")
out.append({"email": email, "name": a.get("displayName", ""), "status": status})
if a.get("self"):
mine = status
return out, mine
def _outlook_attendees(p: dict) -> tuple[list, str]:
"""Outlook attendees → ([{email,name,status}], 내 응답상태)."""
out = []
for a in p.get("attendees", []) or []:
ea = a.get("emailAddress") or {}
addr = ea.get("address", "")
if not addr:
continue
out.append(
{
"email": addr,
"name": ea.get("name", ""),
"status": (a.get("status") or {}).get("response", "none"),
}
)
mine = (p.get("responseStatus") or {}).get("response", "")
return out, mine
# Google Calendar 카테고리 추정 → 내부 calendar.id (없으면 work)
_VALID_CALS = {"work", "meeting", "personal", "team", "health"}
# provider → (calendar id prefix, 표시 라벨, 색 tone)
_CAL_META = {
"google_calendar": ("gcal", "Google 캘린더", "blue"),
"outlook_calendar": ("ocal", "Outlook 캘린더", "coral"),
}
def account_calendar_id(account) -> str:
"""연결된 캘린더 계정별 고유 캘린더 id(이메일 슬러그). 이벤트가 이 캘린더에 묶인다.
Google/Outlook 모두 계정마다 별도 캘린더로 분리(같은 색·이름 충돌 방지)."""
provider = getattr(account, "provider", "") or ""
prefix = _CAL_META.get(provider, ("cal", "캘린더", "blue"))[0]
email = (getattr(account, "external_account_id", "") or "").lower()
return f"{prefix}-{hashlib.sha1(email.encode()).hexdigest()[:8]}" if email else prefix
def ensure_calendar_row(session: Session, account) -> str:
"""연결된 캘린더 계정의 Calendar 행을 보장(없으면 생성, 있으면 이름/색 갱신).
연결 직후 즉시 호출 → 동기화 전이라도 일정 화면 토글에 계정이 노출된다."""
cid = account_calendar_id(account)
_prefix, label, tone = _CAL_META.get(account.provider, ("cal", "캘린더", "blue"))
email = getattr(account, "external_account_id", "") or ""
name = f"{label} · {email}" if email else label
cal = session.get(Calendar, cid)
if cal:
cal.name, cal.tone = name, tone
session.add(cal)
else:
session.add(Calendar(id=cid, name=name, tone=tone, on=True, sort_order=50))
session.commit()
return cid
# 하위호환 별칭(real_google.py 등 기존 import 보존)
gcal_calendar_id = account_calendar_id
ensure_gcal_calendar = ensure_calendar_row
# ── 이벤트 카테고리 분류(공휴일/생일/Gmail 자동 일정 등) → 토글로 숨김 가능 ──
# category id → (표시 이름, 색 tone). 사이드바 캘린더 토글에 그대로 노출된다.
_CATEGORY_META = {
"cat-holiday": ("공휴일", "coral"),
"cat-birthday": ("생일", "violet"),
"cat-gmail": ("Gmail 자동 일정", "faint"),
"cat-ooo": ("자리비움·집중", "amber"),
}
# 제목 기반 공휴일 키워드(휴일 캘린더 미구독 시 폴백).
_HOLIDAY_WORDS = (
"공휴일", "신정", "설날", "삼일절", "어린이날", "부처님", "현충일", "광복절",
"추석", "개천절", "한글날", "성탄절", "크리스마스", "대체공휴일", "제헌절",
"holiday", "christmas", "new year",
)
def classify_event(provider: str, p: dict, title: str) -> str | None:
"""프로바이더 신호(eventType/categories) + 제목으로 특수 카테고리를 판정. 없으면 None."""
t = (title or "").lower()
if provider == "google_calendar":
et = p.get("eventType", "default")
if et == "birthday":
return "cat-birthday"
if et == "fromGmail":
return "cat-gmail"
if et in ("outOfOffice", "focusTime", "workingLocation"):
return "cat-ooo"
elif provider == "outlook_calendar":
cats = [c.lower() for c in (p.get("categories") or [])]
if any(("birthday" in c or "생일" in c) for c in cats):
return "cat-birthday"
if any(("holiday" in c or "휴일" in c) for c in cats):
return "cat-holiday"
# 공통 제목 휴리스틱
if "생일" in (title or "") or "birthday" in t:
return "cat-birthday"
if any(w in (title or "") or w in t for w in _HOLIDAY_WORDS):
return "cat-holiday"
return None
def ensure_category_calendar(session: Session, cat_id: str) -> None:
"""특수 카테고리 Calendar 행 보장(사이드바 토글로 숨김/표시 가능하게)."""
meta = _CATEGORY_META.get(cat_id)
if not meta or session.get(Calendar, cat_id):
return
name, tone = meta
session.add(Calendar(id=cat_id, name=name, tone=tone, on=True, sort_order=80))
def _hhmm(iso: str) -> str:
# "2026-06-10T09:30:00+09:00" → "09:30"
if "T" in iso:
return iso.split("T", 1)[1][:5]
return ""
def _day(iso: str) -> int:
# "2026-06-10..." → 10
try:
return int(iso[8:10])
except (ValueError, IndexError):
return 0
def normalize_event(account, raw: RawRecord, provider: str = "mock") -> NormalizedRecord:
p = raw.payload
if provider == "google_calendar":
start = p.get("start", {}).get("dateTime", p.get("start", {}).get("date", ""))
end = p.get("end", {}).get("dateTime", p.get("end", {}).get("date", ""))
attendees, mine = _google_attendees(p)
rec = p.get("recurrence") or []
rrule = rec[0].replace("RRULE:", "") if rec else ""
reminders = [
o["minutes"]
for o in (p.get("reminders") or {}).get("overrides", [])
if "minutes" in o
]
fields = {
"day": _day(start),
"date": start[:10], # "2026-06-16T.." / "2026-06-16" → "2026-06-16"
"start": _hhmm(start),
"end": _hhmm(end),
"title": p.get("summary", "(제목 없음)"),
"cal": gcal_calendar_id(account), # 연결된 Google 계정 캘린더로 귀속
"loc": p.get("location", ""),
"note": p.get("description", ""),
"soon": False,
"people": ", ".join(a["email"] for a in attendees),
"rrule": rrule,
"reminders": reminders,
"attendees": attendees,
"response_status": mine,
}
elif provider == "outlook_calendar": # Microsoft Graph event
start = p.get("start", {}).get("dateTime", "")
end = p.get("end", {}).get("dateTime", "")
attendees, mine = _outlook_attendees(p)
reminders = (
[p["reminderMinutesBeforeStart"]]
if p.get("isReminderOn") and p.get("reminderMinutesBeforeStart") is not None
else []
)
fields = {
"day": _day(start),
"date": start[:10],
"start": _hhmm(start),
"end": _hhmm(end),
"title": p.get("subject") or "(제목 없음)",
"cal": account_calendar_id(account), # 연결된 Outlook 계정 캘린더로 귀속
"loc": (p.get("location") or {}).get("displayName", ""),
# 조회=bodyPreview, 생성/수정 응답=body.content 폴백.
"note": p.get("bodyPreview") or (p.get("body") or {}).get("content", ""),
"soon": False,
"people": ", ".join(a["email"] for a in attendees),
"rrule": graph_to_rrule(p.get("recurrence")),
"reminders": reminders,
"attendees": attendees,
"response_status": mine,
}
else: # mock / ics — 이미 내부 키와 유사(day/start/end/title/cal/loc/note/soon/people)
cal = (p.get("cal") or "work").lower()
fields = {
"day": int(p.get("day", 0)),
"start": p.get("start", ""),
"end": p.get("end", ""),
"title": p.get("title", ""),
"cal": cal if cal in _VALID_CALS else "work",
"loc": p.get("loc", ""),
"note": p.get("note", ""),
"soon": bool(p.get("soon", False)),
"people": p.get("people", ""),
}
# 특수 카테고리(공휴일/생일/Gmail 자동 일정 등)면 전용 캘린더로 귀속 → 토글로 숨김 가능.
if provider in ("google_calendar", "outlook_calendar"):
cat = classify_event(provider, p, fields["title"])
if cat:
fields["cal"] = cat
return NormalizedRecord(
entity_type="event",
external_id=raw.external_id,
fields=fields,
etag=raw.etag,
external_updated_at=raw.external_updated_at,
)
def ensure_external_link(
session: Session, account, external_id: str, entity_type: str, entity_id: str, etag: str = ""
) -> None:
"""직접 생성/수정한 이벤트를 (account, external_id)로 멱등 매핑(재동기화 중복 방지)."""
link = session.exec(
select(ExternalLink).where(
ExternalLink.account_id == account.id,
ExternalLink.external_id == external_id,
)
).first()
if link:
link.etag = etag
link.entity_id = entity_id
session.add(link)
return
xlid = "xl-" + hashlib.sha1(f"{account.id}:{external_id}".encode()).hexdigest()[:12]
session.add(
ExternalLink(
id=xlid,
account_id=account.id,
external_id=external_id,
entity_type=entity_type,
entity_id=entity_id,
etag=etag,
)
)
def upsert_event(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
cal = norm.fields.get("cal", "")
if isinstance(cal, str) and cal.startswith("cat-"):
ensure_category_calendar(session, cal)
link = session.exec(
select(ExternalLink).where(
ExternalLink.external_id == norm.external_id,
ExternalLink.entity_type == "event",
)
).first()
if link:
ev = session.get(CalEvent, link.entity_id)
if ev:
for k, v in norm.fields.items():
if v is not None and hasattr(ev, k):
setattr(ev, k, v)
session.add(ev)
return ev.id, False
eid = "ce-" + uuid.uuid4().hex[:8]
session.add(CalEvent(id=eid, **{k: v for k, v in norm.fields.items() if v is not None}))
return eid, True