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