r"""Nextcloud Calendar (CalDAV) 모듈 — 일정 CRUD. 사용자 명령 기반 일정 조회/추가/수정/삭제. CalDAV 프로토콜로 iCalendar (VEVENT) 객체를 관리. """ import asyncio import logging import os import re import sys import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Optional sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import config from tools.nextcloud_client import NextcloudClient, DAV_NS, CAL_NS logger = logging.getLogger("variet.tools.nc_calendar") @dataclass class CalEvent: """캘린더 이벤트.""" uid: str summary: str dtstart: datetime dtend: Optional[datetime] = None location: str = "" description: str = "" href: str = "" # CalDAV 리소스 경로 calendar: str = "" # 소속 캘린더 이름 raw_ics: str = "" # 원본 ICS 데이터 @property def duration_str(self) -> str: if self.dtend and self.dtstart: delta = self.dtend - self.dtstart hours = delta.seconds // 3600 mins = (delta.seconds % 3600) // 60 if hours > 0: return f"{hours}시간 {mins}분" if mins else f"{hours}시간" return f"{mins}분" return "" @property def date_str(self) -> str: return self.dtstart.strftime("%Y-%m-%d") @property def time_str(self) -> str: return self.dtstart.strftime("%H:%M") def to_display(self) -> str: """Discord embed용 표시 문자열.""" time = self.time_str dur = f" ({self.duration_str})" if self.duration_str else "" loc = f" 📍{self.location}" if self.location else "" return f"⏰ {time}{dur} — **{self.summary}**{loc}" class NCCalendarClient: """Nextcloud CalDAV 캘린더 클라이언트.""" DEFAULT_CALENDAR = "personal" def __init__(self, nc_client: NextcloudClient = None): self.nc = nc_client or NextcloudClient() def _calendar_path(self, calendar: str = None) -> str: cal = calendar or self.DEFAULT_CALENDAR return f"calendars/{self.nc.username}/{cal}" # ────────────────────────────────────── # 캘린더 목록 # ────────────────────────────────────── async def list_calendars(self) -> list[str]: """사용자 캘린더 목록 조회.""" path = f"calendars/{self.nc.username}/" resp = await self.nc.dav_request( "PROPFIND", path, body=""" """, headers={"Depth": "1"}, ) calendars = [] if resp.status_code == 207: from xml.etree import ElementTree as ET root = ET.fromstring(resp.text) for r in root.findall(f"{{{DAV_NS}}}response"): href = r.find(f"{{{DAV_NS}}}href") propstat = r.find(f"{{{DAV_NS}}}propstat") if propstat is None: continue prop = propstat.find(f"{{{DAV_NS}}}prop") if prop is None: continue rtype = prop.find(f"{{{DAV_NS}}}resourcetype") if rtype is not None and rtype.find(f"{{{CAL_NS}}}calendar") is not None: dn = prop.find(f"{{{DAV_NS}}}displayname") name = dn.text if dn is not None and dn.text else href.text.rstrip("/").split("/")[-1] calendars.append(name) return calendars # ────────────────────────────────────── # 이벤트 조회 # ────────────────────────────────────── async def get_events( self, start: datetime = None, end: datetime = None, calendar: str = None, ) -> list[CalEvent]: """기간 내 이벤트 조회. Args: start: 시작일 (기본: 오늘 00:00) end: 종료일 (기본: start + 7일) calendar: 캘린더 이름 (기본: personal) """ if start is None: start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) if end is None: end = start + timedelta(days=7) start_str = start.strftime("%Y%m%dT%H%M%SZ") end_str = end.strftime("%Y%m%dT%H%M%SZ") body = f""" """ path = self._calendar_path(calendar) resp = await self.nc.dav_request( "REPORT", path, body=body, headers={"Depth": "1"}, ) events = [] if resp.status_code == 207: from xml.etree import ElementTree as ET root = ET.fromstring(resp.text) for r in root.findall(f"{{{DAV_NS}}}response"): href_el = r.find(f"{{{DAV_NS}}}href") href = href_el.text if href_el is not None else "" propstat = r.find(f"{{{DAV_NS}}}propstat") if propstat is None: continue prop = propstat.find(f"{{{DAV_NS}}}prop") if prop is None: continue cal_data = prop.find(f"{{{CAL_NS}}}calendar-data") if cal_data is not None and cal_data.text: event = self._parse_ics(cal_data.text, href, calendar or self.DEFAULT_CALENDAR) if event: events.append(event) events.sort(key=lambda e: e.dtstart) logger.info(f"캘린더 이벤트 조회: {len(events)}건 ({start.date()} ~ {end.date()})") return events async def get_today(self) -> list[CalEvent]: """오늘 일정 조회.""" now = datetime.now() start = now.replace(hour=0, minute=0, second=0, microsecond=0) end = start + timedelta(days=1) return await self.get_events(start, end) async def get_week(self) -> list[CalEvent]: """이번주 일정 조회.""" now = datetime.now() start = now.replace(hour=0, minute=0, second=0, microsecond=0) # 이번주 월요일부터 start = start - timedelta(days=start.weekday()) end = start + timedelta(days=7) return await self.get_events(start, end) # ────────────────────────────────────── # 이벤트 생성 # ────────────────────────────────────── async def create_event( self, summary: str, dtstart: datetime, dtend: datetime = None, location: str = "", description: str = "", calendar: str = None, ) -> CalEvent: """이벤트 생성. Args: summary: 제목 dtstart: 시작 시간 dtend: 종료 시간 (기본: 시작 + 1시간) location: 장소 description: 설명 calendar: 캘린더 이름 """ uid = f"{uuid.uuid4()}@variet-agent" if dtend is None: dtend = dtstart + timedelta(hours=1) ics = self._build_ics(uid, summary, dtstart, dtend, location, description) path = f"{self._calendar_path(calendar)}/{uid}.ics" resp = await self.nc.dav_put(path, ics, "text/calendar; charset=utf-8") if resp.status_code in (200, 201, 204): logger.info(f"일정 생성: {summary} ({dtstart})") return CalEvent( uid=uid, summary=summary, dtstart=dtstart, dtend=dtend, location=location, description=description, href=path, calendar=calendar or self.DEFAULT_CALENDAR, raw_ics=ics, ) else: raise RuntimeError(f"일정 생성 실패: {resp.status_code} {resp.text[:200]}") # ────────────────────────────────────── # 이벤트 수정 # ────────────────────────────────────── async def update_event( self, uid: str, calendar: str = None, summary: str = None, dtstart: datetime = None, dtend: datetime = None, location: str = None, description: str = None, ) -> CalEvent: """이벤트 수정 — 기존 이벤트를 찾아서 변경.""" # 먼저 기존 이벤트를 찾음 events = await self.get_events( start=datetime(2020, 1, 1), end=datetime(2030, 12, 31), calendar=calendar, ) target = None for e in events: if e.uid == uid: target = e break if target is None: raise RuntimeError(f"이벤트 {uid}를 찾을 수 없습니다.") # 변경 적용 new_summary = summary if summary is not None else target.summary new_dtstart = dtstart if dtstart is not None else target.dtstart new_dtend = dtend if dtend is not None else target.dtend new_location = location if location is not None else target.location new_description = description if description is not None else target.description ics = self._build_ics(uid, new_summary, new_dtstart, new_dtend, new_location, new_description) path = target.href or f"{self._calendar_path(calendar)}/{uid}.ics" resp = await self.nc.dav_put(path, ics, "text/calendar; charset=utf-8") if resp.status_code in (200, 201, 204): logger.info(f"일정 수정: {new_summary}") return CalEvent( uid=uid, summary=new_summary, dtstart=new_dtstart, dtend=new_dtend, location=new_location, description=new_description, href=path, calendar=calendar or self.DEFAULT_CALENDAR, ) else: raise RuntimeError(f"일정 수정 실패: {resp.status_code}") # ────────────────────────────────────── # 이벤트 삭제 # ────────────────────────────────────── async def delete_event(self, uid: str, calendar: str = None) -> bool: """이벤트 삭제.""" # href 직접 삭제 시도 path = f"{self._calendar_path(calendar)}/{uid}.ics" ok = await self.nc.dav_delete(path) if ok: logger.info(f"일정 삭제: {uid}") else: logger.warning(f"일정 삭제 실패: {uid}") return ok # ────────────────────────────────────── # ICS 빌드/파싱 # ────────────────────────────────────── @staticmethod def _build_ics( uid: str, summary: str, dtstart: datetime, dtend: datetime, location: str = "", description: str = "", ) -> str: """VEVENT ICS 문자열 생성.""" now = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") ds = dtstart.strftime("%Y%m%dT%H%M%S") de = dtend.strftime("%Y%m%dT%H%M%S") lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Variet-Agent//NC Calendar//KO", "BEGIN:VEVENT", f"UID:{uid}", f"DTSTAMP:{now}", f"DTSTART:{ds}", f"DTEND:{de}", f"SUMMARY:{summary}", ] if location: lines.append(f"LOCATION:{location}") if description: lines.append(f"DESCRIPTION:{description}") lines.extend([ "END:VEVENT", "END:VCALENDAR", ]) return "\r\n".join(lines) @staticmethod def _parse_ics(ics_text: str, href: str = "", calendar: str = "") -> Optional[CalEvent]: """ICS 텍스트에서 CalEvent 추출 (간단 파서).""" def _get_field(text: str, field: str) -> str: pattern = rf"^{field}[;:](.+)$" m = re.search(pattern, text, re.MULTILINE) return m.group(1).strip() if m else "" def _parse_dt(val: str) -> Optional[datetime]: if not val: return None # VALUE=DATE:20260318 형식 처리 if "VALUE=DATE:" in val: val = val.split(":")[-1] elif ":" in val: val = val.split(":")[-1] val = val.replace("Z", "") for fmt in ("%Y%m%dT%H%M%S", "%Y%m%d"): try: return datetime.strptime(val, fmt) except ValueError: continue return None uid = _get_field(ics_text, "UID") summary = _get_field(ics_text, "SUMMARY") dtstart = _parse_dt(_get_field(ics_text, "DTSTART")) dtend = _parse_dt(_get_field(ics_text, "DTEND")) location = _get_field(ics_text, "LOCATION") description = _get_field(ics_text, "DESCRIPTION") if not uid or not dtstart: return None return CalEvent( uid=uid, summary=summary, dtstart=dtstart, dtend=dtend, location=location, description=description, href=href, calendar=calendar, raw_ics=ics_text, ) # ────────────────────────────────────── # CLI # ────────────────────────────────────── async def _cli(): import io if sys.stdout.encoding != "utf-8": sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") args = sys.argv[1:] client = NCCalendarClient() if not args: print("사용법:") print(" nc_calendar.py calendars") print(" nc_calendar.py today") print(" nc_calendar.py week") print(' nc_calendar.py add "제목" 2026-03-20 14:00 [1h]') print(' nc_calendar.py delete ') return if args[0] == "calendars": cals = await client.list_calendars() print(f"📅 캘린더 {len(cals)}개:") for c in cals: print(f" • {c}") elif args[0] == "today": events = await client.get_today() print(f"📅 오늘 일정 ({datetime.now().strftime('%Y-%m-%d')}):\n") if not events: print(" 일정 없음") for e in events: print(f" {e.to_display()}") elif args[0] == "week": events = await client.get_week() print(f"📅 이번주 일정:\n") current_date = "" for e in events: if e.date_str != current_date: current_date = e.date_str print(f"\n 📆 {current_date}") print(f" {e.to_display()}") elif args[0] == "add" and len(args) >= 3: summary = args[1] date_str = args[2] time_str = args[3] if len(args) > 3 else "09:00" duration = args[4] if len(args) > 4 else "1h" dtstart = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") # duration 파싱 dur_match = re.match(r"(\d+)([hm])", duration) if dur_match: val, unit = int(dur_match.group(1)), dur_match.group(2) delta = timedelta(hours=val) if unit == "h" else timedelta(minutes=val) else: delta = timedelta(hours=1) dtend = dtstart + delta event = await client.create_event(summary, dtstart, dtend) print(f"✅ 일정 생성: {event.to_display()}") print(f" UID: {event.uid}") elif args[0] == "delete" and len(args) > 1: uid = args[1] ok = await client.delete_event(uid) print(f"{'✅ 삭제 완료' if ok else '❌ 삭제 실패'}: {uid}") if __name__ == "__main__": asyncio.run(_cli())