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.

95 lines
3.3 KiB
Python

# 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}건 정규화 완료",
}