Add cal-funnel Python app with mergecal fetch/merge/serve loop.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
troogs
2026-07-18 17:46:27 +02:00
commit d70df65dc8
2 changed files with 222 additions and 0 deletions

220
app/main.py Normal file
View File

@@ -0,0 +1,220 @@
import logging
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import httpx
from icalendar import Calendar
from mergecal import merge_calendars
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger("cal-funnel")
USER_AGENT = "cal-funnel/1.0"
def env_int(name: str, default: int) -> int:
value = os.environ.get(name)
if value is None or value.strip() == "":
return default
return int(value)
def parse_urls(raw: str) -> list[str]:
return [url.strip() for url in raw.split(",") if url.strip()]
class Config:
def __init__(self) -> None:
urls_raw = os.environ.get("CALENDAR_URLS", "").strip()
self.urls = parse_urls(urls_raw)
if not self.urls:
raise ValueError("CALENDAR_URLS must contain at least one URL")
self.fetch_interval_seconds = env_int("FETCH_INTERVAL_SECONDS", 900)
self.port = env_int("PORT", 8080)
self.calendar_path = os.environ.get("CALENDAR_PATH", "/calendar.ics").strip() or "/calendar.ics"
self.http_timeout_seconds = env_int("HTTP_TIMEOUT_SECONDS", 30)
self.prodid = os.environ.get("PRODID", "").strip() or None
if not self.calendar_path.startswith("/"):
self.calendar_path = f"/{self.calendar_path}"
class CalendarStore:
def __init__(self) -> None:
self._lock = threading.Lock()
self._data: bytes | None = None
self._event_count = 0
self._updated_at: float | None = None
def set(self, data: bytes, event_count: int) -> None:
with self._lock:
self._data = data
self._event_count = event_count
self._updated_at = time.time()
def snapshot(self) -> tuple[bytes | None, int, float | None]:
with self._lock:
return self._data, self._event_count, self._updated_at
def is_ready(self) -> bool:
with self._lock:
return self._data is not None
def fetch_calendar(client: httpx.Client, url: str) -> Calendar:
response = client.get(url)
response.raise_for_status()
return Calendar.from_ical(response.content)
def count_events(calendar: Calendar) -> int:
return len(list(calendar.walk("VEVENT")))
def merge_feeds(config: Config, client: httpx.Client) -> tuple[bytes, int]:
calendars: list[Calendar] = []
failed: list[str] = []
for url in config.urls:
try:
calendar = fetch_calendar(client, url)
calendars.append(calendar)
logger.info("Fetched %s (%d events)", url, count_events(calendar))
except Exception as exc:
failed.append(url)
logger.error("Failed to fetch %s: %s", url, exc)
if not calendars:
raise RuntimeError(f"All calendar feeds failed: {', '.join(failed)}")
if failed:
logger.warning("Merged %d/%d feeds; failed: %s", len(calendars), len(config.urls), ", ".join(failed))
merged = merge_calendars(calendars)
if config.prodid:
merged["PRODID"] = config.prodid
data = merged.to_ical()
event_count = count_events(merged)
return data, event_count
def refresh_loop(config: Config, store: CalendarStore, stop_event: threading.Event) -> None:
headers = {"User-Agent": USER_AGENT}
timeout = httpx.Timeout(config.http_timeout_seconds)
with httpx.Client(headers=headers, timeout=timeout, follow_redirects=True) as client:
while not stop_event.is_set():
try:
data, event_count = merge_feeds(config, client)
store.set(data, event_count)
logger.info("Merged calendar updated (%d events)", event_count)
except Exception as exc:
logger.error("Refresh failed: %s", exc)
if stop_event.wait(config.fetch_interval_seconds):
break
def make_handler(config: Config, store: CalendarStore):
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args) -> None:
logger.info("%s - %s", self.address_string(), format % args)
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/health":
if store.is_ready():
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"ok")
else:
self.send_response(503)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"not ready")
return
if path == config.calendar_path:
data, event_count, updated_at = store.snapshot()
if data is None:
self.send_response(503)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"Calendar not ready yet")
return
self.send_response(200)
self.send_header("Content-Type", "text/calendar; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
if updated_at is not None:
self.send_header("X-Cal-Funnel-Events", str(event_count))
self.end_headers()
self.wfile.write(data)
return
if path == "/":
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
body = (
"<!doctype html><html><head><title>Cal-Funnel</title></head>"
"<body><h1>Cal-Funnel</h1>"
f"<p>Subscribe to the merged calendar at "
f"<a href=\"{config.calendar_path}\">{config.calendar_path}</a>.</p>"
"</body></html>"
)
self.wfile.write(body.encode("utf-8"))
return
self.send_response(404)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"Not found")
return Handler
def main() -> None:
config = Config()
store = CalendarStore()
stop_event = threading.Event()
worker = threading.Thread(
target=refresh_loop,
args=(config, store, stop_event),
name="refresh-loop",
daemon=True,
)
worker.start()
server = ThreadingHTTPServer(("0.0.0.0", config.port), make_handler(config, store))
logger.info(
"Serving merged calendar on port %d at %s (refresh every %ds)",
config.port,
config.calendar_path,
config.fetch_interval_seconds,
)
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("Shutting down")
finally:
stop_event.set()
server.shutdown()
if __name__ == "__main__":
main()

2
app/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
mergecal==0.5.0
httpx>=0.27.0