Skip to content

Commit 31ce5ad

Browse files
authored
Merge pull request #16 from XenonMolecule/calendar
Added Calendar Observer
2 parents dc0f3c0 + af42e58 commit 31ce5ad

File tree

2 files changed

+376
-1
lines changed

2 files changed

+376
-1
lines changed

gum/observers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66

77
from .observer import Observer
88
from .screen import Screen
9+
from .calendar import Calendar
910

10-
__all__ = ["Observer", "Screen"]
11+
__all__ = ["Observer", "Screen", "Calendar"]

gum/observers/calendar.py

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
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')}\nNo 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

Comments
 (0)