# backend/app/connectors/calendar/ics_import.py — .ics 파일 임포트(로컬 우선, OAuth 불필요) # 의존성 없이 VEVENT 를 직접 파싱 → cal_event 정규화로 귀결. from datetime import UTC, datetime from sqlmodel import Session from ..base import BaseConnector, NormalizedRecord, RawRecord from .normalize import normalize_event, upsert_event def _unfold(text: str) -> list[str]: """RFC5545 라인 언폴딩(다음 줄이 공백/탭으로 시작하면 이어붙임).""" out: list[str] = [] for line in text.replace("\r\n", "\n").split("\n"): if line[:1] in (" ", "\t") and out: out[-1] += line[1:] else: out.append(line) return out def _prop(line: str) -> tuple[str, str]: # "DTSTART;TZID=Asia/Seoul:20260610T090000" → ("DTSTART", "20260610T090000") name, _, value = line.partition(":") return name.split(";", 1)[0].upper(), value.strip() def parse_ics(text: str) -> list[dict]: events: list[dict] = [] cur: dict | None = None for line in _unfold(text): key, val = _prop(line) if key == "BEGIN" and val == "VEVENT": cur = {} elif key == "END" and val == "VEVENT": if cur is not None: events.append(cur) cur = None elif cur is not None: if key == "UID": cur["id"] = val elif key == "SUMMARY": cur["title"] = val elif key == "LOCATION": cur["loc"] = val elif key == "DESCRIPTION": cur["note"] = val elif key == "DTSTART": cur["_start_raw"] = val elif key == "DTEND": cur["_end_raw"] = val for e in events: sr, er = e.pop("_start_raw", ""), e.pop("_end_raw", "") e["day"] = int(sr[6:8]) if len(sr) >= 8 and sr[6:8].isdigit() else 0 e["start"] = f"{sr[9:11]}:{sr[11:13]}" if "T" in sr else "" e["end"] = f"{er[9:11]}:{er[11:13]}" if "T" in er else "" e["cal"] = "work" e.setdefault("id", f"ics-{e.get('title', '')[:8]}-{sr}") return events class IcsConnector(BaseConnector): domain = "calendar" entity_type = "event" def fetch(self, session: Session, *, full: bool = False): for p in getattr(self, "_events", []): yield RawRecord( external_id=p["id"], payload=p, etag=f"{p.get('start', '')}-{p.get('title', '')}", external_updated_at=datetime.now(UTC), ) def normalize(self, raw: RawRecord) -> NormalizedRecord: return normalize_event(self.account, raw, provider="ics") def write(self, session: Session, norm: NormalizedRecord): return upsert_event(session, norm) def event_for(self, norm, entity_id): return "calendar.updated" def import_bytes(self, session: Session, content: bytes, filename: str = "") -> dict: text = content.decode("utf-8", "ignore") self._events = parse_ics(text) res = self.sync(session, full=True) return { "entity_type": "event", "imported": res.upserted, "skipped": res.skipped, "errors": res.errors, "detail": f"{filename or '.ics'} · 이벤트 {res.upserted}건 정규화 완료", }