From 316c6101f69d6f49e200d4c66674a0884b892057 Mon Sep 17 00:00:00 2001 From: "Gerald F. Fruhmann" Date: Thu, 19 Feb 2026 18:32:00 +0100 Subject: [PATCH 1/7] Feat: CalDAV REPORT sync, multi-calendar selection & agent datetime awareness Replace inefficient PROPFIND+GET per-event sync with CalDAV REPORT time-range queries (RFC 4791), reducing ~2900 HTTP requests to ~6 and sync time from ~10 minutes to ~2 seconds. Add multi-calendar discovery and selection via Settings GUI. Inject current datetime into agent system prompt and strengthen tool-use instructions so the LLM reliably calls find_event instead of hallucinating calendar data. Co-Authored-By: Claude Opus 4.6 --- config/soul.md | 13 +- src/niles/agent/core.py | 4 +- src/niles/agent/prompts.py | 43 ++-- src/niles/config.py | 1 + src/niles/main.py | 1 + src/niles/settings_store.py | 1 + src/niles/sources/web.py | 27 +++ src/niles/sync/caldav.py | 196 +++++++++++++++---- src/niles/templates/fragments/calendars.html | 22 +++ src/niles/templates/settings.html | 13 ++ tests/test_caldav.py | 134 +++++++++---- tests/test_settings_store.py | 1 + 12 files changed, 357 insertions(+), 99 deletions(-) create mode 100644 src/niles/templates/fragments/calendars.html diff --git a/config/soul.md b/config/soul.md index 2b9d385b..42f2e917 100644 --- a/config/soul.md +++ b/config/soul.md @@ -23,10 +23,13 @@ Du bist Niles, ein persönlicher AI-Assistent. Du läufst lokal auf dem Mac Mini ### Kalender -- Nutze `find_event` um Termine zu suchen (nach Stichwort und/oder Datum) +- Du hast Zugriff auf den Kalender des Benutzers. Erfinde NIEMALS Termine. +- Wenn der Benutzer nach Terminen fragt, rufe IMMER `find_event` auf. Antworte NIEMALS aus dem Gedächtnis. +- "Nächster Termin", "was steht an", "Termine diese Woche" → rufe `find_event` auf (query leer lassen für alle) +- Suche nach bestimmtem Termin → `find_event` mit query (z.B. "Zahnarzt", "Padel") +- Termine in einem Zeitraum → `find_event` mit date_from und/oder date_to (ISO-Format, z.B. "2026-02-20") - Nutze `create_event` um neue Termine zu erstellen -- Bei Fragen wie "nächster Termin" oder "was steht an": rufe `find_event` auf -- Gib Termine mit Datum, Uhrzeit und Ort aus +- Gib Termine immer mit Wochentag, Datum, Uhrzeit und Ort aus ### Gedächtnis @@ -39,4 +42,6 @@ Du bist Niles, ein persönlicher AI-Assistent. Du läufst lokal auf dem Mac Mini 1. Wenn du eine Aktion ausführst, bestätige kurz was du getan hast 2. Wenn du einen Kontakt nicht findest, frage nach der Telefonnummer 3. Bei Grupennachrichten: Stelle sicher dass du den richtigen Gruppennamen hast -4. Führe NIEMALS Aktionen aus ohne explizite Aufforderung +4. Sende NIEMALS WhatsApp-Nachrichten ohne explizite Aufforderung +5. Kalender-Abfragen (find_event) und Kontakt-Suchen (find_contact) darfst du IMMER selbstständig aufrufen +6. Erfinde NIEMALS Informationen. Wenn du etwas nicht weißt, nutze die Tools um es herauszufinden. diff --git a/src/niles/agent/core.py b/src/niles/agent/core.py index 793b761f..5805cf91 100644 --- a/src/niles/agent/core.py +++ b/src/niles/agent/core.py @@ -213,7 +213,9 @@ async def process_event(self, event: dict) -> str: # Load memory context for system prompt memories = await self.memory.list_all() - system_prompt = build_system_prompt(self.base_prompt, memories) + system_prompt = build_system_prompt( + self.base_prompt, memories, timezone=self.config.timezone, + ) # Load conversation history history_messages = await self.history.get_recent(chat_id) diff --git a/src/niles/agent/prompts.py b/src/niles/agent/prompts.py index a7651100..3a1fea90 100644 --- a/src/niles/agent/prompts.py +++ b/src/niles/agent/prompts.py @@ -1,7 +1,9 @@ """System prompt loading and building.""" import logging +from datetime import datetime from pathlib import Path +from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) @@ -26,21 +28,32 @@ def load_system_prompt(path: str | None = None) -> str: return _DEFAULT_PROMPT -def build_system_prompt(base_prompt: str, memories: list[dict]) -> str: - """Build full system prompt with memory context.""" - if not memories: - return base_prompt +def build_system_prompt( + base_prompt: str, memories: list[dict], timezone: str = "Europe/Vienna", +) -> str: + """Build full system prompt with current datetime and memory context.""" + tz = ZoneInfo(timezone) + now = datetime.now(tz) + weekdays_de = [ + "Montag", "Dienstag", "Mittwoch", "Donnerstag", + "Freitag", "Samstag", "Sonntag", + ] + weekday = weekdays_de[now.weekday()] + + time_section = ( + f"\n\n## Aktuelle Zeit\n" + f"Heute ist {weekday}, der {now.strftime('%d.%m.%Y')}. " + f"Es ist {now.strftime('%H:%M')} Uhr ({timezone})." + ) - memory_lines = [] - for entry in memories: - key = entry["key"] - value = entry["value"] - memory_lines.append(f"- {key}: {value}") + prompt = base_prompt + time_section - memory_section = ( - "\n\n## Dein Gedächtnis\n" - "Folgende Dinge hast du dir gemerkt:\n" - + "\n".join(memory_lines) - ) + if memories: + memory_lines = [f"- {e['key']}: {e['value']}" for e in memories] + prompt += ( + "\n\n## Dein Gedächtnis\n" + "Folgende Dinge hast du dir gemerkt:\n" + + "\n".join(memory_lines) + ) - return base_prompt + memory_section + return prompt diff --git a/src/niles/config.py b/src/niles/config.py index d55974fa..e0de44ec 100644 --- a/src/niles/config.py +++ b/src/niles/config.py @@ -63,6 +63,7 @@ class Settings(BaseSettings): caldav_url: str = "https://dav.mailbox.org/caldav/" caldav_user: str = "" caldav_password: str = "" + caldav_calendars: str = "" # Comma-separated collection hrefs, empty = all # Google OAuth (optional -- Web-UI login) google_client_id: str = "" diff --git a/src/niles/main.py b/src/niles/main.py index f7b07504..84a96b08 100644 --- a/src/niles/main.py +++ b/src/niles/main.py @@ -173,6 +173,7 @@ async def lifespan(app: FastAPI): app.state.history = history app.state.settings_store = settings_store app.state.user_store = user_store + app.state.caldav = caldav_sync if settings.feature_caldav_sync else None yield diff --git a/src/niles/settings_store.py b/src/niles/settings_store.py index 97deb94a..3e760009 100644 --- a/src/niles/settings_store.py +++ b/src/niles/settings_store.py @@ -24,6 +24,7 @@ "feature_tool_send_whatsapp", "feature_carddav_sync", "feature_caldav_sync", + "caldav_calendars", } _KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$") diff --git a/src/niles/sources/web.py b/src/niles/sources/web.py index 7850de41..0b188fb6 100644 --- a/src/niles/sources/web.py +++ b/src/niles/sources/web.py @@ -206,6 +206,7 @@ def _safe_settings_dict(settings) -> dict: "text_settings": text_settings, "general": {"timezone": settings.timezone, "log_level": settings.log_level}, "infra": infra, + "caldav_enabled": settings.feature_caldav_sync, } @@ -560,3 +561,29 @@ async def update_setting(request: Request, key: str, value: str = Form(...)): "message": f"'{key}' gespeichert", "toast_type": "success", }) + + +@router.get("/api/caldav/calendars", response_class=HTMLResponse) +async def caldav_calendars(request: Request): + """Discover available CalDAV calendars, return checkboxes fragment.""" + user = _get_session_user(request) + if user is None: + return Response(status_code=401, headers={"HX-Redirect": "/ui/login"}) + + caldav = getattr(request.app.state, "caldav", None) + if not caldav: + return HTMLResponse("

CalDAV nicht konfiguriert.

") + + try: + collections = await caldav.discover_collections() + except Exception: + logger.exception("CalDAV collection discovery failed") + return HTMLResponse("

Fehler beim Laden der Kalender.

") + + # Determine which are currently selected + selected = caldav._allowed_collections() + + return templates.TemplateResponse(request, "fragments/calendars.html", { + "collections": collections, + "selected": selected, + }) diff --git a/src/niles/sync/caldav.py b/src/niles/sync/caldav.py index e04ee82c..04114e8b 100644 --- a/src/niles/sync/caldav.py +++ b/src/niles/sync/caldav.py @@ -26,6 +26,30 @@ r"<(?:[dD]:)?href[^>]*>\s*([^<]*\.ics)\s*", re.IGNORECASE ) +# Regex to extract calendar-data from REPORT response +_CALENDAR_DATA_REGEX = re.compile( + r"<(?:CAL:|C:)?calendar-data[^>]*>(.*?)", + re.IGNORECASE | re.DOTALL, +) + +# Sync window: 30 days past, 365 days future +_SYNC_DAYS_PAST = 30 +_SYNC_DAYS_FUTURE = 365 + +# Regex for collection hrefs (paths ending with /) +_COLLECTION_HREF_REGEX = re.compile( + r"<(?:[dD]:)?href[^>]*>\s*([^<]+/)\s*", re.IGNORECASE +) + +# Regex to extract href + displayname from a block +_RESPONSE_BLOCK_REGEX = re.compile( + r"<(?:[dD]:)?response[^>]*>(.*?)", + re.IGNORECASE | re.DOTALL, +) +_DISPLAYNAME_REGEX = re.compile( + r"<(?:[dD]:)?displayname[^>]*>([^<]*)", re.IGNORECASE +) + # Regex to parse DTSTART/DTEND lines with optional parameters _DT_LINE_REGEX = re.compile(r"(DTSTART|DTEND)([^:]*):(.+)") @@ -105,6 +129,7 @@ class CalDAVSync: def __init__(self, pool: asyncpg.Pool, config: Settings): self.pool = pool + self.config = config self.caldav_url = config.caldav_url self.auth = httpx.BasicAuth(config.caldav_user, config.caldav_password) self.tz = ZoneInfo(config.timezone) @@ -138,80 +163,175 @@ async def initialize(self) -> None: logger.info("Events table initialized") async def sync_events(self) -> int: - """Run a full CalDAV sync. Returns number of synced events.""" + """Run a CalDAV sync using REPORT with time-range filter. + + Only syncs events from 30 days ago to 365 days in the future. + Uses CalDAV REPORT (RFC 4791) to fetch matching events inline, + avoiding thousands of individual GET requests. + """ logger.info("Starting CalDAV event sync...") + now = datetime.now(timezone.utc) + start = (now - timedelta(days=_SYNC_DAYS_PAST)).strftime("%Y%m%dT%H%M%SZ") + end = (now + timedelta(days=_SYNC_DAYS_FUTURE)).strftime("%Y%m%dT%H%M%SZ") + + # Discover collections try: - ics_urls = await self._propfind() + collections = await self._get_sync_collections() except Exception: - logger.exception("PROPFIND failed") + logger.exception("Collection discovery failed") return 0 - if not ics_urls: - logger.warning("No iCalendar URLs found") + if not collections: + logger.warning("No calendar collections found") return 0 - logger.info("Found %d iCalendar URLs", len(ics_urls)) - count = 0 - for url in ics_urls: + for col_url in collections: try: - ics_text = await self._fetch_ics(url) - if not ics_text: - continue - - event = self._parse_icalendar(ics_text, url) - if not event: - continue - - await self._upsert_event(event) - count += 1 + events = await self._report_time_range(col_url, start, end) + for ics_text, href in events: + event = self._parse_icalendar(ics_text, href) + if event: + await self._upsert_event(event) + count += 1 except Exception: - logger.exception("Failed to sync event: %s", url) + logger.exception("REPORT failed for %s", col_url) - logger.info("Synced %d events", count) + logger.info("Synced %d events (range: %s to %s)", count, start, end) return count - async def _propfind(self) -> list[str]: - """Send PROPFIND request and extract .ics URLs from response.""" + async def _report_time_range( + self, collection_url: str, start: str, end: str, + ) -> list[tuple[str, str]]: + """Send CalDAV REPORT with time-range filter. Returns [(ics_text, href), ...].""" + body = ( + '' + '' + "" + "" + "" + f'' + "" + "" + ) + async with httpx.AsyncClient() as client: response = await client.request( - "PROPFIND", - self.caldav_url, - content=_PROPFIND_BODY, + "REPORT", + collection_url, + content=body, headers={ "Depth": "1", "Content-Type": "application/xml; charset=utf-8", }, auth=self.auth, - timeout=30, + timeout=60, ) response.raise_for_status() xml = response.text - if not xml or len(xml) < 100: - logger.warning("Empty or too short PROPFIND response") + results: list[tuple[str, str]] = [] + + for block in _RESPONSE_BLOCK_REGEX.finditer(xml): + block_text = block.group(1) + href_match = _HREF_REGEX.search(block_text) + href = href_match.group(1).strip() if href_match else "" + cal_match = _CALENDAR_DATA_REGEX.search(block_text) + if cal_match: + ics_text = cal_match.group(1).strip() + if "BEGIN:VCALENDAR" in ics_text: + results.append((ics_text, href)) + + logger.info(" %s: %d events in time range", collection_url, len(results)) + return results + + async def _get_sync_collections(self) -> list[str]: + """Get collection URLs to sync, respecting caldav_calendars filter.""" + xml = await self._propfind_request(self.caldav_url) + if not xml: return [] - urls = _HREF_REGEX.findall(xml) - return [u.strip() for u in urls if u.strip()] + # Direct calendar URL? (has .ics files directly) + if _HREF_REGEX.search(xml): + return [self.caldav_url] + + # Discover sub-collections + root_path = self.caldav_url.replace(self._base_url, "").rstrip("/") + "/" + collection_hrefs = _COLLECTION_HREF_REGEX.findall(xml) + collections = [ + h.strip() + for h in collection_hrefs + if h.strip() != root_path and "schedule-" not in h + ] + + allowed = self._allowed_collections() + if allowed: + collections = [h for h in collections if h in allowed] + + logger.info("Syncing %d calendar collections", len(collections)) + + return [ + self._base_url + h if not h.startswith("http") else h + for h in collections + ] + + async def discover_collections(self) -> list[dict]: + """Discover available calendar collections from the CalDAV root. + + Returns list of {"href": "/caldav/abc/", "name": "Kalender"} dicts. + """ + xml = await self._propfind_request(self.caldav_url) + if not xml: + return [] + + root_path = self.caldav_url.replace(self._base_url, "").rstrip("/") + "/" + collections: list[dict] = [] + + for block_match in _RESPONSE_BLOCK_REGEX.finditer(xml): + block = block_match.group(1) + href_match = _COLLECTION_HREF_REGEX.search(block) + if not href_match: + continue + href = href_match.group(1).strip() + if href == root_path or "schedule-" in href: + continue + + name_match = _DISPLAYNAME_REGEX.search(block) + name = name_match.group(1).strip() if name_match else href + collections.append({"href": href, "name": name}) + + return collections + + def _allowed_collections(self) -> set[str] | None: + """Parse caldav_calendars setting into a set of allowed hrefs, or None for all.""" + raw = self.config.caldav_calendars + if not raw or not raw.strip(): + return None + return {h.strip() for h in raw.split(",") if h.strip()} - async def _fetch_ics(self, url: str) -> str | None: - """Fetch a single iCalendar resource by URL.""" - full_url = self._base_url + url if not url.startswith("http") else url + async def _propfind_request(self, url: str) -> str | None: + """Send a single PROPFIND Depth:1 request, return XML or None.""" async with httpx.AsyncClient() as client: - response = await client.get( - full_url, + response = await client.request( + "PROPFIND", + url, + content=_PROPFIND_BODY, + headers={ + "Depth": "1", + "Content-Type": "application/xml; charset=utf-8", + }, auth=self.auth, timeout=30, ) response.raise_for_status() - text = response.text - if "BEGIN:VCALENDAR" not in text: + xml = response.text + if not xml or len(xml) < 100: + logger.warning("Empty or too short PROPFIND response for %s", url) return None - return text + return xml def _parse_icalendar(self, ics_text: str, url: str) -> dict | None: """Parse iCalendar text into an event dict. Returns None if invalid.""" diff --git a/src/niles/templates/fragments/calendars.html b/src/niles/templates/fragments/calendars.html new file mode 100644 index 00000000..ba915c26 --- /dev/null +++ b/src/niles/templates/fragments/calendars.html @@ -0,0 +1,22 @@ +
+ {% for col in collections %} + + {% endfor %} + {% if collections %} + + + {% else %} +

Keine Kalender gefunden.

+ {% endif %} +
diff --git a/src/niles/templates/settings.html b/src/niles/templates/settings.html index 6a8c500f..78b6111f 100644 --- a/src/niles/templates/settings.html +++ b/src/niles/templates/settings.html @@ -57,6 +57,19 @@

General

+{% if caldav_enabled %} +
+

CalDAV Kalender

+

Waehle aus, welche Kalender synchronisiert werden sollen. Aenderungen wirken beim naechsten Sync.

+
+

Kalender werden geladen...

+
+
+{% endif %} +

Infrastructure (read-only)

Diese Werte koennen nur in der .env Datei geaendert werden (Container-Neustart noetig).

diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 5feded20..95f9dd8e 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -34,6 +34,59 @@ """ +# Root-level response listing calendar collections (no .ics files) +SAMPLE_PROPFIND_ROOT_XML = """ + + + /caldav/ + + Calendars + + + + /caldav/Y2FsOi8vMC8zMQ/ + + Kalender + + + + /caldav/Y2FsOi8vMTUvMA/ + + SK Sturm Graz + + + + /caldav/schedule-inbox/ + + Schedule Inbox + + + + /caldav/schedule-outbox/ + + Schedule Outbox + + +""" + +# Collection-level response with .ics files +SAMPLE_PROPFIND_COLLECTION_XML = """ + + + /caldav/Y2FsOi8vMC8zMQ/ + + Kalender + + + + /caldav/Y2FsOi8vMC8zMQ/meeting.ics + + + + +""" + SAMPLE_ICS_FULL = """BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -126,38 +179,37 @@ async def test_creates_table_and_indexes(self, sync, pool): assert "idx_events_summary" in calls[2] -class TestPropfind: - async def test_extracts_ics_urls(self, sync): - mock_response = MagicMock() - mock_response.text = SAMPLE_PROPFIND_XML - mock_response.raise_for_status = MagicMock() - - with patch("niles.sync.caldav.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.request.return_value = mock_response - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) +class TestGetSyncCollections: + async def test_direct_calendar_url(self, sync): + """When URL has .ics files directly, return that URL.""" + with patch.object(sync, "_propfind_request", return_value=SAMPLE_PROPFIND_XML): + urls = await sync._get_sync_collections() - urls = await sync._propfind() + assert urls == ["https://dav.mailbox.org/caldav/123/"] - assert len(urls) == 2 - assert "/caldav/123/event1.ics" in urls - assert "/caldav/123/event2.ics" in urls + async def test_returns_empty_on_no_response(self, sync): + with patch.object(sync, "_propfind_request", return_value=None): + urls = await sync._get_sync_collections() - async def test_returns_empty_on_short_response(self, sync): - mock_response = MagicMock() - mock_response.text = "" - mock_response.raise_for_status = MagicMock() + assert urls == [] - with patch("niles.sync.caldav.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.request.return_value = mock_response - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + async def test_discovers_collections_from_root(self, pool): + """When URL is root, discover sub-collections.""" + root_sync = CalDAVSync(pool, Settings( + postgres_password="test", + evolution_api_key="test", + caldav_url="https://dav.mailbox.org/caldav/", + caldav_user="testuser", + caldav_password="testpass", + )) - urls = await sync._propfind() + with patch.object(root_sync, "_propfind_request", return_value=SAMPLE_PROPFIND_ROOT_XML): + urls = await root_sync._get_sync_collections() - assert urls == [] + assert len(urls) == 2 + assert any("Y2FsOi8vMC8zMQ" in u for u in urls) + assert any("Y2FsOi8vMTUvMA" in u for u in urls) + assert not any("schedule-" in u for u in urls) class TestUnfoldIcs: @@ -333,26 +385,26 @@ async def test_upsert_executes_query(self, sync, pool): class TestSyncEvents: async def test_full_sync_flow(self, sync, pool): - with patch.object(sync, "_propfind", return_value=[ - "/caldav/123/event1.ics", - ]) as mock_propfind, \ - patch.object(sync, "_fetch_ics", return_value=SAMPLE_ICS_FULL), \ + with patch.object(sync, "_get_sync_collections", return_value=[ + "https://dav.mailbox.org/caldav/123/", + ]), \ + patch.object(sync, "_report_time_range", return_value=[ + (SAMPLE_ICS_FULL, "/caldav/123/event1.ics"), + ]), \ patch.object(sync, "_upsert_event") as mock_upsert: count = await sync.sync_events() assert count == 1 - mock_propfind.assert_called_once() mock_upsert.assert_called_once() async def test_sync_skips_invalid_events(self, sync, pool): - with patch.object(sync, "_propfind", return_value=[ - "/caldav/123/good.ics", - "/caldav/123/bad.ics", + with patch.object(sync, "_get_sync_collections", return_value=[ + "https://dav.mailbox.org/caldav/123/", ]), \ - patch.object(sync, "_fetch_ics", side_effect=[ - SAMPLE_ICS_FULL, - SAMPLE_ICS_NO_SUMMARY, + patch.object(sync, "_report_time_range", return_value=[ + (SAMPLE_ICS_FULL, "/caldav/123/good.ics"), + (SAMPLE_ICS_NO_SUMMARY, "/caldav/123/bad.ics"), ]), \ patch.object(sync, "_upsert_event") as mock_upsert: @@ -361,14 +413,14 @@ async def test_sync_skips_invalid_events(self, sync, pool): assert count == 1 mock_upsert.assert_called_once() - async def test_sync_returns_zero_on_propfind_failure(self, sync): - with patch.object(sync, "_propfind", side_effect=Exception("Network error")): + async def test_sync_returns_zero_on_discovery_failure(self, sync): + with patch.object(sync, "_get_sync_collections", side_effect=Exception("Network")): count = await sync.sync_events() assert count == 0 - async def test_sync_returns_zero_when_no_urls(self, sync): - with patch.object(sync, "_propfind", return_value=[]): + async def test_sync_returns_zero_when_no_collections(self, sync): + with patch.object(sync, "_get_sync_collections", return_value=[]): count = await sync.sync_events() assert count == 0 diff --git a/tests/test_settings_store.py b/tests/test_settings_store.py index 56ee053b..98018427 100644 --- a/tests/test_settings_store.py +++ b/tests/test_settings_store.py @@ -12,6 +12,7 @@ def test_contains_expected_keys(self): "llm_base_url", "llm_model", "timezone", "log_level", "feature_whatsapp_auto_reply", "feature_tool_send_whatsapp", "feature_carddav_sync", "feature_caldav_sync", + "caldav_calendars", } assert EDITABLE_SETTINGS == expected From 81a87c0169d9c37f01ab6a96d8f8edd6652ba4f1 Mon Sep 17 00:00:00 2001 From: "Gerald F. Fruhmann" Date: Thu, 19 Feb 2026 18:32:52 +0100 Subject: [PATCH 2/7] Chore: Expose Postgres port for local debugging Co-Authored-By: Claude Opus 4.6 --- docker/docker-compose.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 57f15878..f0faf28d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,8 +7,9 @@ services: container_name: niles_evolution_postgres image: postgres:15-alpine restart: unless-stopped - # Port not exposed -- access only via Docker network - # For local debugging: ports: ["127.0.0.1:5432:5432"] + # Port exposed for local debugging + ports: + - "127.0.0.1:5432:5432" environment: POSTGRES_USER: evolution POSTGRES_PASSWORD: ${EVOLUTION_POSTGRES_PASSWORD} From 4702e5d754b5a5410fd9b63e345f36167768ba74 Mon Sep 17 00:00:00 2001 From: "Gerald F. Fruhmann" Date: Thu, 19 Feb 2026 18:44:55 +0100 Subject: [PATCH 3/7] Fix: PR #15 security review -- all 6 findings addressed 1. SSRF: Reject CalDAV hrefs with foreign origins (non-matching base URL) 2. Response size: Cap CalDAV REPORT/PROPFIND responses at 10 MB 3. Prompt injection: Sanitize event fields (strip control chars, truncate) before passing to LLM context 4. Value validation: Reject settings values exceeding 4096 characters 5. Timezone validation: Verify IANA timezone in SettingsStore.set() and graceful fallback in build_system_prompt() 6. XML parsing: Replace all regex-based XML parsing with xml.etree.ElementTree for robustness against comments, CDATA, and namespace variations Co-Authored-By: Claude Opus 4.6 --- src/niles/actions/calendar.py | 23 ++++++- src/niles/agent/prompts.py | 7 ++- src/niles/settings_store.py | 17 +++++ src/niles/sync/caldav.py | 113 ++++++++++++++++++---------------- 4 files changed, 102 insertions(+), 58 deletions(-) diff --git a/src/niles/actions/calendar.py b/src/niles/actions/calendar.py index ac3a424d..b23022b4 100644 --- a/src/niles/actions/calendar.py +++ b/src/niles/actions/calendar.py @@ -1,6 +1,7 @@ """Calendar event lookup in PostgreSQL.""" import logging +import re from datetime import datetime from zoneinfo import ZoneInfo @@ -8,6 +9,10 @@ logger = logging.getLogger(__name__) +# Strip control characters except common whitespace (space, tab) +_CONTROL_CHAR_REGEX = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") +_MAX_FIELD_LENGTH = 500 + class CalendarAction: """Search calendar events by keyword and/or date range.""" @@ -74,13 +79,25 @@ def _parse_date(self, value: str, end_of_day: bool = False) -> datetime | None: logger.warning("Failed to parse date: %s", value) return None + @staticmethod + def _sanitize_field(value: str) -> str: + """Sanitize a text field from external calendar data. + + Strips control characters and truncates to prevent prompt injection + when event data is passed to the LLM context. + """ + clean = _CONTROL_CHAR_REGEX.sub("", value) + if len(clean) > _MAX_FIELD_LENGTH: + clean = clean[:_MAX_FIELD_LENGTH] + "..." + return clean + def _row_to_dict(self, row: asyncpg.Record) -> dict: """Convert a database row to a formatted dict.""" dtstart = row["dtstart"] dtend = row["dtend"] result = { - "summary": row["summary"], + "summary": self._sanitize_field(row["summary"]), "start": dtstart.astimezone(self.tz).isoformat() if dtstart else None, "all_day": row["all_day"], } @@ -88,8 +105,8 @@ def _row_to_dict(self, row: asyncpg.Record) -> dict: if dtend: result["end"] = dtend.astimezone(self.tz).isoformat() if row["description"]: - result["description"] = row["description"] + result["description"] = self._sanitize_field(row["description"]) if row["location"]: - result["location"] = row["location"] + result["location"] = self._sanitize_field(row["location"]) return result diff --git a/src/niles/agent/prompts.py b/src/niles/agent/prompts.py index 3a1fea90..b429decf 100644 --- a/src/niles/agent/prompts.py +++ b/src/niles/agent/prompts.py @@ -32,7 +32,12 @@ def build_system_prompt( base_prompt: str, memories: list[dict], timezone: str = "Europe/Vienna", ) -> str: """Build full system prompt with current datetime and memory context.""" - tz = ZoneInfo(timezone) + try: + tz = ZoneInfo(timezone) + except (KeyError, ValueError): + logger.warning("Invalid timezone '%s', falling back to Europe/Vienna", timezone) + timezone = "Europe/Vienna" + tz = ZoneInfo(timezone) now = datetime.now(tz) weekdays_de = [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", diff --git a/src/niles/settings_store.py b/src/niles/settings_store.py index 3e760009..1a1f29f7 100644 --- a/src/niles/settings_store.py +++ b/src/niles/settings_store.py @@ -9,6 +9,7 @@ import logging import re from typing import Any +from zoneinfo import ZoneInfo import asyncpg @@ -74,6 +75,22 @@ async def set(self, key: str, value: Any) -> None: _validate_key(key) if key not in EDITABLE_SETTINGS: raise ValueError(f"Setting '{key}' is not editable at runtime") + + # Value length guard for string settings + if isinstance(value, str) and len(value) > 4096: + raise ValueError( + f"Value for '{key}' exceeds maximum length of 4096 characters" + ) + + # Validate timezone is a valid IANA identifier + if key == "timezone" and isinstance(value, str): + try: + ZoneInfo(value) + except (KeyError, ValueError) as exc: + raise ValueError( + f"Invalid timezone: '{value}' is not a valid IANA timezone" + ) from exc + async with self.pool.acquire() as conn: async with conn.transaction(): await conn.execute( diff --git a/src/niles/sync/caldav.py b/src/niles/sync/caldav.py index 04114e8b..c7c92dbd 100644 --- a/src/niles/sync/caldav.py +++ b/src/niles/sync/caldav.py @@ -3,6 +3,7 @@ import logging import re import uuid +import xml.etree.ElementTree as ET from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo @@ -21,34 +22,18 @@ "" ) -# Namespace-agnostic regex for href elements containing .ics paths -_HREF_REGEX = re.compile( - r"<(?:[dD]:)?href[^>]*>\s*([^<]*\.ics)\s*", re.IGNORECASE -) - -# Regex to extract calendar-data from REPORT response -_CALENDAR_DATA_REGEX = re.compile( - r"<(?:CAL:|C:)?calendar-data[^>]*>(.*?)", - re.IGNORECASE | re.DOTALL, -) +# XML namespaces used in CalDAV responses +_NS = { + "D": "DAV:", + "C": "urn:ietf:params:xml:ns:caldav", +} # Sync window: 30 days past, 365 days future _SYNC_DAYS_PAST = 30 _SYNC_DAYS_FUTURE = 365 -# Regex for collection hrefs (paths ending with /) -_COLLECTION_HREF_REGEX = re.compile( - r"<(?:[dD]:)?href[^>]*>\s*([^<]+/)\s*", re.IGNORECASE -) - -# Regex to extract href + displayname from a block -_RESPONSE_BLOCK_REGEX = re.compile( - r"<(?:[dD]:)?response[^>]*>(.*?)", - re.IGNORECASE | re.DOTALL, -) -_DISPLAYNAME_REGEX = re.compile( - r"<(?:[dD]:)?displayname[^>]*>([^<]*)", re.IGNORECASE -) +# Maximum response size from CalDAV server (10 MB) +_MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # Regex to parse DTSTART/DTEND lines with optional parameters _DT_LINE_REGEX = re.compile(r"(DTSTART|DTEND)([^:]*):(.+)") @@ -229,17 +214,21 @@ async def _report_time_range( timeout=60, ) response.raise_for_status() + if len(response.content) > _MAX_RESPONSE_BYTES: + raise ValueError( + f"CalDAV REPORT response too large: {len(response.content)} bytes" + ) - xml = response.text + xml_text = response.text results: list[tuple[str, str]] = [] - for block in _RESPONSE_BLOCK_REGEX.finditer(xml): - block_text = block.group(1) - href_match = _HREF_REGEX.search(block_text) - href = href_match.group(1).strip() if href_match else "" - cal_match = _CALENDAR_DATA_REGEX.search(block_text) - if cal_match: - ics_text = cal_match.group(1).strip() + root = ET.fromstring(xml_text) + for resp_el in root.findall("D:response", _NS): + href_el = resp_el.find("D:href", _NS) + href = (href_el.text or "").strip() if href_el is not None else "" + cal_el = resp_el.find(".//C:calendar-data", _NS) + if cal_el is not None and cal_el.text: + ics_text = cal_el.text.strip() if "BEGIN:VCALENDAR" in ics_text: results.append((ics_text, href)) @@ -248,21 +237,26 @@ async def _report_time_range( async def _get_sync_collections(self) -> list[str]: """Get collection URLs to sync, respecting caldav_calendars filter.""" - xml = await self._propfind_request(self.caldav_url) - if not xml: + xml_text = await self._propfind_request(self.caldav_url) + if not xml_text: return [] + root = ET.fromstring(xml_text) + hrefs = [ + (el.text or "").strip() + for el in root.findall(".//D:response/D:href", _NS) + if el.text + ] + # Direct calendar URL? (has .ics files directly) - if _HREF_REGEX.search(xml): + if any(h.endswith(".ics") for h in hrefs): return [self.caldav_url] - # Discover sub-collections + # Discover sub-collections (hrefs ending with /) root_path = self.caldav_url.replace(self._base_url, "").rstrip("/") + "/" - collection_hrefs = _COLLECTION_HREF_REGEX.findall(xml) collections = [ - h.strip() - for h in collection_hrefs - if h.strip() != root_path and "schedule-" not in h + h for h in hrefs + if h.endswith("/") and h != root_path and "schedule-" not in h ] allowed = self._allowed_collections() @@ -271,35 +265,42 @@ async def _get_sync_collections(self) -> list[str]: logger.info("Syncing %d calendar collections", len(collections)) - return [ - self._base_url + h if not h.startswith("http") else h - for h in collections - ] + # Build full URLs; reject absolute hrefs that don't match our origin (SSRF protection) + urls: list[str] = [] + for h in collections: + if h.startswith("http"): + if not h.startswith(self._base_url): + logger.warning("Ignoring href with foreign origin: %s", h) + continue + urls.append(h) + else: + urls.append(self._base_url + h) + return urls async def discover_collections(self) -> list[dict]: """Discover available calendar collections from the CalDAV root. Returns list of {"href": "/caldav/abc/", "name": "Kalender"} dicts. """ - xml = await self._propfind_request(self.caldav_url) - if not xml: + xml_text = await self._propfind_request(self.caldav_url) + if not xml_text: return [] root_path = self.caldav_url.replace(self._base_url, "").rstrip("/") + "/" collections: list[dict] = [] - for block_match in _RESPONSE_BLOCK_REGEX.finditer(xml): - block = block_match.group(1) - href_match = _COLLECTION_HREF_REGEX.search(block) - if not href_match: + root = ET.fromstring(xml_text) + for resp_el in root.findall("D:response", _NS): + href_el = resp_el.find("D:href", _NS) + if href_el is None or not href_el.text: continue - href = href_match.group(1).strip() - if href == root_path or "schedule-" in href: + href = href_el.text.strip() + if not href.endswith("/") or href == root_path or "schedule-" in href: continue - name_match = _DISPLAYNAME_REGEX.search(block) - name = name_match.group(1).strip() if name_match else href - collections.append({"href": href, "name": name}) + name_el = resp_el.find(".//D:displayname", _NS) + name = (name_el.text or "").strip() if name_el is not None else "" + collections.append({"href": href, "name": name or href}) return collections @@ -326,6 +327,10 @@ async def _propfind_request(self, url: str) -> str | None: timeout=30, ) response.raise_for_status() + if len(response.content) > _MAX_RESPONSE_BYTES: + raise ValueError( + f"CalDAV PROPFIND response too large: {len(response.content)} bytes" + ) xml = response.text if not xml or len(xml) < 100: From 22bc83cf08a9c56f2b31af167d85ef03720bdbb8 Mon Sep 17 00:00:00 2001 From: "Gerald F. Fruhmann" Date: Thu, 19 Feb 2026 18:55:51 +0100 Subject: [PATCH 4/7] Fix: PR #15 re-review -- CSP, tests, code quality, docker security 1. CSP: Move inline onclick handler in calendars.html to event-delegated handler in app.js (inline scripts blocked by CSP) 2. Test: Add test_filters_by_caldav_calendars_setting for calendar selection filter path 3. API: Rename _allowed_collections to allowed_collections (public, called from web.py) 4. Style: Remove unnecessary f-prefix in prompts.py, fix extra blank line in caldav.py 5. Docker: Revert postgres port exposure to commented-out default Co-Authored-By: Claude Opus 4.6 --- docker/docker-compose.yml | 5 ++--- src/niles/agent/prompts.py | 2 +- src/niles/sources/web.py | 2 +- src/niles/static/js/app.js | 9 +++++++++ src/niles/sync/caldav.py | 5 ++--- src/niles/templates/fragments/calendars.html | 6 +----- tests/test_caldav.py | 18 ++++++++++++++++++ 7 files changed, 34 insertions(+), 13 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f0faf28d..57f15878 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,9 +7,8 @@ services: container_name: niles_evolution_postgres image: postgres:15-alpine restart: unless-stopped - # Port exposed for local debugging - ports: - - "127.0.0.1:5432:5432" + # Port not exposed -- access only via Docker network + # For local debugging: ports: ["127.0.0.1:5432:5432"] environment: POSTGRES_USER: evolution POSTGRES_PASSWORD: ${EVOLUTION_POSTGRES_PASSWORD} diff --git a/src/niles/agent/prompts.py b/src/niles/agent/prompts.py index b429decf..c1f05120 100644 --- a/src/niles/agent/prompts.py +++ b/src/niles/agent/prompts.py @@ -46,7 +46,7 @@ def build_system_prompt( weekday = weekdays_de[now.weekday()] time_section = ( - f"\n\n## Aktuelle Zeit\n" + "\n\n## Aktuelle Zeit\n" f"Heute ist {weekday}, der {now.strftime('%d.%m.%Y')}. " f"Es ist {now.strftime('%H:%M')} Uhr ({timezone})." ) diff --git a/src/niles/sources/web.py b/src/niles/sources/web.py index 0b188fb6..d2d07f90 100644 --- a/src/niles/sources/web.py +++ b/src/niles/sources/web.py @@ -581,7 +581,7 @@ async def caldav_calendars(request: Request): return HTMLResponse("

Fehler beim Laden der Kalender.

") # Determine which are currently selected - selected = caldav._allowed_collections() + selected = caldav.allowed_collections() return templates.TemplateResponse(request, "fragments/calendars.html", { "collections": collections, diff --git a/src/niles/static/js/app.js b/src/niles/static/js/app.js index 1f391f98..6a5c3b23 100644 --- a/src/niles/static/js/app.js +++ b/src/niles/static/js/app.js @@ -43,6 +43,15 @@ document.body.addEventListener("change", function(evt) { htmx.trigger(form, "submit"); }); +/* Calendar save -- collect checked values into hidden field before submit (CSP-safe) */ +document.body.addEventListener("click", function(evt) { + if (!evt.target.hasAttribute("data-calendar-save")) return; + var form = evt.target.closest("form"); + var boxes = form.querySelectorAll("input[name=cal]:checked"); + var vals = Array.from(boxes).map(function(b) { return b.value; }); + form.querySelector("#cal-value").value = vals.join(","); +}); + document.body.addEventListener("htmx:afterRequest", function(evt) { var btn = evt.detail.elt.querySelector("button[type='submit']"); if (btn) btn.removeAttribute("aria-busy"); diff --git a/src/niles/sync/caldav.py b/src/niles/sync/caldav.py index c7c92dbd..f2ff2369 100644 --- a/src/niles/sync/caldav.py +++ b/src/niles/sync/caldav.py @@ -259,7 +259,7 @@ async def _get_sync_collections(self) -> list[str]: if h.endswith("/") and h != root_path and "schedule-" not in h ] - allowed = self._allowed_collections() + allowed = self.allowed_collections() if allowed: collections = [h for h in collections if h in allowed] @@ -304,14 +304,13 @@ async def discover_collections(self) -> list[dict]: return collections - def _allowed_collections(self) -> set[str] | None: + def allowed_collections(self) -> set[str] | None: """Parse caldav_calendars setting into a set of allowed hrefs, or None for all.""" raw = self.config.caldav_calendars if not raw or not raw.strip(): return None return {h.strip() for h in raw.split(",") if h.strip()} - async def _propfind_request(self, url: str) -> str | None: """Send a single PROPFIND Depth:1 request, return XML or None.""" async with httpx.AsyncClient() as client: diff --git a/src/niles/templates/fragments/calendars.html b/src/niles/templates/fragments/calendars.html index ba915c26..66102c6c 100644 --- a/src/niles/templates/fragments/calendars.html +++ b/src/niles/templates/fragments/calendars.html @@ -11,11 +11,7 @@ {% endfor %} {% if collections %} - + {% else %}

Keine Kalender gefunden.

{% endif %} diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 95f9dd8e..819a2945 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -211,6 +211,24 @@ async def test_discovers_collections_from_root(self, pool): assert any("Y2FsOi8vMTUvMA" in u for u in urls) assert not any("schedule-" in u for u in urls) + async def test_filters_by_caldav_calendars_setting(self, pool): + """When caldav_calendars is set, only matching collections are returned.""" + root_sync = CalDAVSync(pool, Settings( + postgres_password="test", + evolution_api_key="test", + caldav_url="https://dav.mailbox.org/caldav/", + caldav_user="testuser", + caldav_password="testpass", + caldav_calendars="/caldav/Y2FsOi8vMC8zMQ/", + )) + + with patch.object(root_sync, "_propfind_request", return_value=SAMPLE_PROPFIND_ROOT_XML): + urls = await root_sync._get_sync_collections() + + assert len(urls) == 1 + assert "Y2FsOi8vMC8zMQ" in urls[0] + assert not any("Y2FsOi8vMTUvMA" in u for u in urls) + class TestUnfoldIcs: def test_unfolds_space_continuation(self): From 8f09b27df77f71e788afae93b428d99f20ca74b4 Mon Sep 17 00:00:00 2001 From: "Gerald F. Fruhmann" Date: Thu, 19 Feb 2026 18:59:17 +0100 Subject: [PATCH 5/7] Fix: Docker postgres port env var guard Use POSTGRES_HOST_PORT env var (default: 0 = random ephemeral port on loopback, effectively hidden). Set POSTGRES_HOST_PORT=5432 in .env to expose for local debugging. Co-Authored-By: Claude Opus 4.6 --- .env.example | 4 ++++ docker/docker-compose.yml | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index e90030d3..973064ae 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,10 @@ EVOLUTION_API_KEY=your-secure-api-key-here # Connection: postgresql://evolution:PASSWORD@evolution_postgres:5432/evolution_db EVOLUTION_POSTGRES_PASSWORD=your-secure-database-password-here +# Expose Postgres port for local debugging (default: random port, not discoverable) +# Uncomment to bind to a fixed port: +#POSTGRES_HOST_PORT=5432 + # Niles API Key (for /chat endpoint authentication) # Auto-generated at startup if not set (retrieve with: docker exec niles_core printenv NILES_API_KEY) # Set manually for a stable key across container restarts diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 57f15878..dabda57d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,8 +7,10 @@ services: container_name: niles_evolution_postgres image: postgres:15-alpine restart: unless-stopped - # Port not exposed -- access only via Docker network - # For local debugging: ports: ["127.0.0.1:5432:5432"] + # Postgres port: loopback only, random port by default (not discoverable). + # For debugging, set in .env: POSTGRES_HOST_PORT=5432 + ports: + - "127.0.0.1:${POSTGRES_HOST_PORT:-0}:5432" environment: POSTGRES_USER: evolution POSTGRES_PASSWORD: ${EVOLUTION_POSTGRES_PASSWORD} From 1f14c5af5a3cd6b02c86d527dc136b3ed855df79 Mon Sep 17 00:00:00 2001 From: "Gerald F. Fruhmann" Date: Thu, 19 Feb 2026 18:59:57 +0100 Subject: [PATCH 6/7] Docs: Document POSTGRES_HOST_PORT env var for debugging Co-Authored-By: Claude Opus 4.6 --- docs/Development.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/Development.md b/docs/Development.md index 5c635811..3c36420f 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -100,6 +100,14 @@ curl -k -X POST https://localhost/chat \ Alternativ direkt ueber den Docker-internen Port (ohne TLS): `docker exec niles_core curl http://localhost:8000/health` +**Postgres Debugging:** Der Postgres-Port ist standardmaessig nicht erreichbar. Um direkt auf die Datenbank zuzugreifen (z.B. via `psql`), in `.env` setzen: + +```bash +POSTGRES_HOST_PORT=5432 +``` + +Dann: `psql -h 127.0.0.1 -U evolution -d evolution_db` + ### Status pruefen ```bash From b4df9d199506d2a549ce3600be19ae8eaf284f5b Mon Sep 17 00:00:00 2001 From: "Gerald F. Fruhmann" Date: Thu, 19 Feb 2026 19:13:35 +0100 Subject: [PATCH 7/7] Fix: PR #15 third review -- stale config, empty selection, discovery cache 1. Stale config: Update caldav.config after runtime settings change so allowed_collections() reads fresh caldav_calendars without restart 2. Empty selection UX: Disable save button when no calendar is checked, prevent submitting empty value (which would mean "sync all") 3. Discovery cache: Cache discover_collections() results for 60s to avoid repeated PROPFIND requests on settings page interactions Co-Authored-By: Claude Opus 4.6 --- src/niles/sources/web.py | 7 ++++++- src/niles/static/js/app.js | 13 +++++++++++++ src/niles/sync/caldav.py | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/niles/sources/web.py b/src/niles/sources/web.py index d2d07f90..3d9c57d7 100644 --- a/src/niles/sources/web.py +++ b/src/niles/sources/web.py @@ -550,7 +550,12 @@ async def update_setting(request: Request, key: str, value: str = Form(...)): try: await settings_store.set(key, parsed_value) - request.app.state.settings = apply_overrides(settings, {key: parsed_value}) + new_settings = apply_overrides(settings, {key: parsed_value}) + request.app.state.settings = new_settings + # Keep CalDAV sync config in sync so allowed_collections() reads fresh data + caldav = getattr(request.app.state, "caldav", None) + if caldav: + caldav.config = new_settings except ValueError as e: return templates.TemplateResponse(request, "fragments/toast.html", { "message": str(e), diff --git a/src/niles/static/js/app.js b/src/niles/static/js/app.js index 6a5c3b23..193d4898 100644 --- a/src/niles/static/js/app.js +++ b/src/niles/static/js/app.js @@ -48,10 +48,23 @@ document.body.addEventListener("click", function(evt) { if (!evt.target.hasAttribute("data-calendar-save")) return; var form = evt.target.closest("form"); var boxes = form.querySelectorAll("input[name=cal]:checked"); + if (boxes.length === 0) { + evt.preventDefault(); + return; + } var vals = Array.from(boxes).map(function(b) { return b.value; }); form.querySelector("#cal-value").value = vals.join(","); }); +/* Calendar checkboxes -- disable save button when nothing is checked */ +document.body.addEventListener("change", function(evt) { + if (evt.target.name !== "cal") return; + var form = evt.target.closest("form"); + var btn = form.querySelector("[data-calendar-save]"); + var checked = form.querySelectorAll("input[name=cal]:checked").length; + btn.disabled = checked === 0; +}); + document.body.addEventListener("htmx:afterRequest", function(evt) { var btn = evt.detail.elt.querySelector("button[type='submit']"); if (btn) btn.removeAttribute("aria-busy"); diff --git a/src/niles/sync/caldav.py b/src/niles/sync/caldav.py index f2ff2369..bfd22d30 100644 --- a/src/niles/sync/caldav.py +++ b/src/niles/sync/caldav.py @@ -2,6 +2,7 @@ import logging import re +import time import uuid import xml.etree.ElementTree as ET from datetime import datetime, timedelta, timezone @@ -35,6 +36,9 @@ # Maximum response size from CalDAV server (10 MB) _MAX_RESPONSE_BYTES = 10 * 1024 * 1024 +# Cache TTL for discover_collections (seconds) +_DISCOVERY_CACHE_TTL = 60 + # Regex to parse DTSTART/DTEND lines with optional parameters _DT_LINE_REGEX = re.compile(r"(DTSTART|DTEND)([^:]*):(.+)") @@ -121,6 +125,9 @@ def __init__(self, pool: asyncpg.Pool, config: Settings): # Base URL for fetching individual .ics files (scheme + host) self._base_url = re.match(r"https?://[^/]+", config.caldav_url) self._base_url = self._base_url.group(0) if self._base_url else "" + # Cache for discover_collections (avoids PROPFIND on every settings page load) + self._collections_cache: list[dict] | None = None + self._collections_cache_time: float = 0 async def initialize(self) -> None: """Create events table and indexes if they don't exist.""" @@ -281,7 +288,12 @@ async def discover_collections(self) -> list[dict]: """Discover available calendar collections from the CalDAV root. Returns list of {"href": "/caldav/abc/", "name": "Kalender"} dicts. + Results are cached for 60 seconds to avoid repeated PROPFIND requests. """ + now = time.monotonic() + if self._collections_cache is not None and (now - self._collections_cache_time) < _DISCOVERY_CACHE_TTL: + return self._collections_cache + xml_text = await self._propfind_request(self.caldav_url) if not xml_text: return [] @@ -302,6 +314,8 @@ async def discover_collections(self) -> list[dict]: name = (name_el.text or "").strip() if name_el is not None else "" collections.append({"href": href, "name": name or href}) + self._collections_cache = collections + self._collections_cache_time = time.monotonic() return collections def allowed_collections(self) -> set[str] | None: