1+ from __future__ import annotations
2+ import os
3+ import aiohttp
4+ import asyncio
5+ import json
6+ from datetime import datetime , timedelta
7+ from zoneinfo import ZoneInfo
8+ from typing import Optional , Dict , List
9+ from ics import Calendar as IcsCalendar
10+
11+ from .observer import Observer
12+ from ..schemas import Update
13+
14+
15+ ###############################################################################
16+ # Calendar Observer (persistent cache + batching + polling)
17+ ###############################################################################
18+
19+ class Calendar (Observer ):
20+ """
21+ Observer that monitors an ICS calendar feed for event additions,
22+ deletions, or modifications and emits *batched, chronologically
23+ ordered* updates.
24+
25+ ⚠️ TODO (TZ semantics)
26+ Outlook ICS feeds often export *local wall-clock times* labeled as UTC ("Z").
27+ This observer therefore treats all event times as local via
28+ `.replace(tzinfo=self.local_tz)`. If you use a properly
29+ timezone-aware ICS (e.g., Google), you may remove this substitution.
30+ """
31+
32+ def __init__ (
33+ self ,
34+ ics_url : Optional [str ] = None ,
35+ polling_interval : int = 60 , # poll every 60 s
36+ snapshot_interval : int = 24 * 3600 , # daily summary
37+ timezone : Optional [str ] = None , # auto-detect if not provided
38+ debug : bool = False ,
39+ ) -> None :
40+ self .ics_url = ics_url or os .getenv ("CALENDAR_ICS" )
41+ if not self .ics_url :
42+ raise ValueError (
43+ "No ICS URL provided. Pass via constructor or CALENDAR_ICS environment variable.\n "
44+ "See https://learn.microsoft.com/en-us/answers/questions/4617072/how-to-export-outlook-calendar-to-ics-link for more information."
45+ )
46+
47+ self .polling_interval = polling_interval
48+ self .snapshot_interval = snapshot_interval
49+
50+ # auto-detect system timezone if not specified
51+ if timezone is None :
52+ try :
53+ detected_tz = datetime .now ().astimezone ().tzinfo
54+ self .local_tz = ZoneInfo (str (detected_tz ))
55+ if debug :
56+ print (f"[Calendar] Auto-detected local timezone: { self .local_tz } " )
57+ except Exception :
58+ self .local_tz = ZoneInfo ("America/Los_Angeles" )
59+ if debug :
60+ print ("[Calendar] Failed to detect local timezone, defaulting to America/Los_Angeles" )
61+ else :
62+ self .local_tz = ZoneInfo (timezone )
63+
64+ self .debug = debug
65+
66+ # persistent cache on disk
67+ self .cache_dir = os .path .expanduser ("~/.cache/gum/calendar" )
68+ os .makedirs (self .cache_dir , exist_ok = True )
69+ self .cache_path = os .path .join (self .cache_dir , "calendar_cache.json" )
70+ self ._cache : Dict [str , Dict ] = self ._load_cache ()
71+
72+ self ._last_snapshot_time = datetime .now (self .local_tz )
73+
74+ super ().__init__ ()
75+
76+ # ─────────────────────────────── cache helpers
77+ def _load_cache (self ) -> Dict [str , Dict ]:
78+ try :
79+ if os .path .exists (self .cache_path ):
80+ with open (self .cache_path , "r" ) as f :
81+ raw = json .load (f )
82+ for uid , info in raw .items ():
83+ for k in ("start" , "end" ):
84+ if info .get (k ):
85+ info [k ] = datetime .fromisoformat (info [k ])
86+ if self .debug :
87+ print (f"[Calendar] Loaded { len (raw )} cached events." )
88+ return raw
89+ except Exception as e :
90+ if self .debug :
91+ print (f"[Calendar] Failed to load cache: { e } " )
92+ return {}
93+
94+ def _save_cache (self ) -> None :
95+ try :
96+ serializable = {
97+ uid : {
98+ ** info ,
99+ "start" : info ["start" ].isoformat () if info .get ("start" ) else None ,
100+ "end" : info ["end" ].isoformat () if info .get ("end" ) else None ,
101+ }
102+ for uid , info in self ._cache .items ()
103+ }
104+ with open (self .cache_path , "w" ) as f :
105+ json .dump (serializable , f , indent = 2 )
106+ if self .debug :
107+ print (f"[Calendar] Cache saved ({ len (serializable )} events)." )
108+ except Exception as e :
109+ if self .debug :
110+ print (f"[Calendar] Failed to save cache: { e } " )
111+
112+ # ─────────────────────────────── background worker
113+ async def _worker (self ) -> None :
114+ while self ._running :
115+ changed = await self ._poll_once ()
116+ if changed :
117+ self ._save_cache ()
118+
119+ now = datetime .now (self .local_tz )
120+ if (now - self ._last_snapshot_time ).total_seconds () > self .snapshot_interval :
121+ await self ._emit_snapshot ()
122+ self ._last_snapshot_time = now
123+
124+ await asyncio .sleep (self .polling_interval )
125+
126+ # ─────────────────────────────── ICS fetch + parse
127+ async def _fetch_calendar (self ) -> Optional [List ]:
128+ async with aiohttp .ClientSession () as session :
129+ async with session .get (self .ics_url ) as resp :
130+ if resp .status != 200 :
131+ raise RuntimeError (f"Bad status: { resp .status } " )
132+ body = await resp .text ()
133+ cal = IcsCalendar (body )
134+ return list (cal .events )
135+
136+ # ─────────────────────────────── poll & diff
137+ async def _poll_once (self ) -> bool :
138+ """Fetch ICS, diff with cache (last 7 days), and emit one batched update."""
139+ try :
140+ events = await self ._fetch_calendar ()
141+ except Exception as e :
142+ if self .debug :
143+ print (f"[Calendar] Fetch failed: { e } " )
144+ return False
145+ if not events :
146+ return False
147+
148+ now = datetime .now (self .local_tz )
149+ one_week_ago = now - timedelta (days = 7 )
150+ new_state : Dict [str , Dict ] = {}
151+
152+ for ev in events :
153+ if not ev .begin :
154+ continue
155+ start = ev .begin .datetime .replace (tzinfo = self .local_tz )
156+ end = ev .end .datetime .replace (tzinfo = self .local_tz ) if ev .end else None
157+ if start < one_week_ago :
158+ continue
159+
160+ new_state [ev .uid ] = {
161+ "uid" : ev .uid ,
162+ "title" : ev .name or "<no title>" ,
163+ "start" : start ,
164+ "end" : end ,
165+ "desc" : (ev .description or "" ).strip (),
166+ "loc" : ev .location or "<no location>" ,
167+ }
168+
169+ added , removed , modified = [], [], []
170+
171+ for uid , info in new_state .items ():
172+ if uid not in self ._cache :
173+ added .append (info )
174+ else :
175+ old = self ._cache [uid ]
176+ if any (info [k ] != old .get (k ) for k in ("title" , "start" , "end" , "desc" , "loc" )):
177+ modified .append (info )
178+ for uid in self ._cache :
179+ if uid not in new_state :
180+ removed .append (self ._cache [uid ])
181+
182+ self ._cache = new_state
183+
184+ if not (added or removed or modified ):
185+ if self .debug :
186+ print ("[Calendar] No changes detected." )
187+ return False
188+
189+ # always batch chronologically
190+ changes = []
191+ for group , kind in ((added , "NEW" ), (modified , "MODIFIED" ), (removed , "DELETED" )):
192+ for ev in group :
193+ changes .append ((ev ["start" ], kind , ev ))
194+ changes .sort (key = lambda x : x [0 ])
195+
196+ content = self ._format_batch_update (changes , now )
197+ await self .update_queue .put (Update (content = content , content_type = "input_text" ))
198+
199+ if self .debug :
200+ print (f"[Calendar] Emitted batch update ({ len (changes )} events)." )
201+ return True
202+
203+ # ─────────────────────────────── format batched updates
204+ def _format_batch_update (self , sorted_changes : List [tuple ], current_time : datetime ) -> str :
205+ lines = [f"Current Time: { current_time .strftime ('%Y-%m-%d %H:%M %Z' )} " ]
206+ for start , kind , ev in sorted_changes :
207+ delta = ev ["start" ] - current_time
208+ total = int (delta .total_seconds ())
209+ neg = total < 0
210+ total = abs (total )
211+ days , rem = divmod (total , 86400 )
212+ hours , rem = divmod (rem , 3600 )
213+ minutes = rem // 60
214+ delta_str = f"{ days } d { hours } h { minutes } m"
215+ if neg :
216+ delta_str += " ago"
217+
218+ lines += [
219+ f"{ kind } calendar event:" ,
220+ f" Title : { ev ['title' ]} " ,
221+ f" When : { ev ['start' ]} → { ev ['end' ]} " ,
222+ f" Location : { ev ['loc' ]} " ,
223+ f" Starts In : { delta_str } " ,
224+ ]
225+ if ev ["desc" ]:
226+ lines .append (" Description:" )
227+ for line in ev ["desc" ].splitlines ():
228+ lines .append (f" { line } " )
229+ # event delimiter
230+ lines .append ("\n ----------\n " )
231+ return "\n " .join (lines ).strip ()
232+
233+ # ─────────────────────────────── daily snapshot
234+ async def _emit_snapshot (self ) -> None :
235+ try :
236+ events = await self ._fetch_calendar ()
237+ except Exception as e :
238+ if self .debug :
239+ print (f"[Calendar] Snapshot fetch failed: { e } " )
240+ return
241+ if not events :
242+ return
243+
244+ now = datetime .now (self .local_tz )
245+ week_ahead = now + timedelta (days = 7 )
246+ future_events = [
247+ {
248+ "title" : ev .name or "<no title>" ,
249+ "start" : ev .begin .datetime .replace (tzinfo = self .local_tz ),
250+ "end" : ev .end .datetime .replace (tzinfo = self .local_tz ) if ev .end else None ,
251+ "loc" : ev .location or "<no location>" ,
252+ "desc" : (ev .description or "" ).strip (),
253+ }
254+ for ev in events
255+ if ev .begin and now <= ev .begin .datetime .replace (tzinfo = self .local_tz ) <= week_ahead
256+ ]
257+ future_events .sort (key = lambda e : e ["start" ])
258+
259+ lines = [
260+ f"Current Time: { now .strftime ('%Y-%m-%d %H:%M %Z' )} " ,
261+ "Weekly Calendar Snapshot:" ,
262+ ]
263+ for ev in future_events :
264+ lines += [
265+ f" Title : { ev ['title' ]} " ,
266+ f" When : { ev ['start' ]} → { ev ['end' ]} " ,
267+ f" Location : { ev ['loc' ]} " ,
268+ ]
269+ if ev ["desc" ]:
270+ lines .append (" Description:" )
271+ for line in ev ["desc" ].splitlines ():
272+ lines .append (f" { line } " )
273+ lines .append ("" )
274+
275+ await self .update_queue .put (Update (content = "\n " .join (lines ), content_type = "input_text" ))
276+ if self .debug :
277+ print ("[Calendar] Snapshot emitted." )
278+
279+ # ─────────────────────────────── public query API
280+ def query (
281+ self ,
282+ start_delta : timedelta = timedelta (seconds = 0 ),
283+ end_delta : timedelta = timedelta (days = 1 ),
284+ ) -> List [Dict ]:
285+ """
286+ Query cached calendar events within a time window relative to now.
287+
288+ Args:
289+ start_delta (timedelta, optional): Offset from now for window start.
290+ Defaults to 0 (i.e., now).
291+ end_delta (timedelta, optional): Offset from now for window end.
292+ Defaults to 1 day.
293+
294+ Returns:
295+ List[Dict]: List of events (dicts) in the requested timeframe.
296+ """
297+ if not self ._cache :
298+ if self .debug :
299+ print ("[Calendar] Cache is empty; nothing to query." )
300+ return []
301+
302+ now = datetime .now (self .local_tz )
303+ start_time = now + start_delta
304+ end_time = now + end_delta
305+
306+ results = []
307+ for ev in self ._cache .values ():
308+ ev_start = ev ["start" ]
309+ ev_end = ev ["end" ] or ev_start
310+ if ev_end >= start_time and ev_start <= end_time :
311+ results .append (ev )
312+
313+ results .sort (key = lambda e : e ["start" ])
314+ if self .debug :
315+ print (f"[Calendar] Query returned { len (results )} events from { start_time } to { end_time } ." )
316+ return results
317+
318+ def query_str (
319+ self ,
320+ start_delta : timedelta = timedelta (seconds = 0 ),
321+ end_delta : timedelta = timedelta (days = 1 ),
322+ ) -> str :
323+ """
324+ Return a formatted string representation of cached events
325+ within a time window relative to now.
326+
327+ Uses the same formatting style as the batched updates emitted
328+ by the observer, for human readability.
329+
330+ Args:
331+ start_delta (timedelta, optional): Offset from now for window start.
332+ Defaults to 0 (now).
333+ end_delta (timedelta, optional): Offset from now for window end.
334+ Defaults to 1 day.
335+
336+ Returns:
337+ str: Readable text of calendar events in the time range.
338+ """
339+ events = self .query (start_delta = start_delta , end_delta = end_delta )
340+ now = datetime .now (self .local_tz )
341+ if not events :
342+ return f"Current Time: { now .strftime ('%Y-%m-%d %H:%M %Z' )} \n No events found in this range."
343+
344+ lines = [
345+ f"Current Time: { now .strftime ('%Y-%m-%d %H:%M %Z' )} " ,
346+ f"Calendar Events ({ len (events )} found):" ,
347+ ]
348+
349+ for ev in events :
350+ delta = ev ["start" ] - now
351+ total = int (delta .total_seconds ())
352+ neg = total < 0
353+ total = abs (total )
354+ days , rem = divmod (total , 86400 )
355+ hours , rem = divmod (rem , 3600 )
356+ minutes = rem // 60
357+ delta_str = f"{ days } d { hours } h { minutes } m"
358+ if neg :
359+ delta_str += " ago"
360+
361+ lines += [
362+ f" Title : { ev ['title' ]} " ,
363+ f" When : { ev ['start' ]} → { ev ['end' ]} " ,
364+ f" Location : { ev ['loc' ]} " ,
365+ f" Starts In : { delta_str } " ,
366+ ]
367+ if ev ["desc" ]:
368+ lines .append (" Description:" )
369+ for line in ev ["desc" ].splitlines ():
370+ lines .append (f" { line } " )
371+ # visual delimiter between events
372+ lines .append ("\n ----------\n " )
373+
374+ return "\n " .join (lines ).strip ()
0 commit comments