# Apple Calendar CalDAV Setup

## Prerequisites

```bash
python3 -m pip install caldav vobject feedparser
```

If pip is missing:
```bash
curl -sS https://bootstrap.pypa.io/get-pip.py | python3
python3 -m pip install caldav vobject feedparser
```

## iCloud App-Specific Password

Apple requires an **app-specific password** for CalDAV access. Your main iCloud password will NOT work.

1. Go to [appleid.apple.com](https://appleid.apple.com)
2. Sign in → **Sign-In and Security** → **App-Specific Passwords**
3. Generate a new password (format: `xxxx-xxxx-xxxx-xxxx`)
4. Use this in your `.env` file, not your real password

## .env File

Create `/home/$USER/.hermes/.env`:

```bash
APPLE_EMAIL="your@icloud.com"
APPLE_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
```

Set permissions:
```bash
chmod 600 /home/$USER/.hermes/.env
```

## CalDAV URL

```python
CALDAV_URL = "https://caldav.icloud.com"
```

## Common Issues

### `sys` module not imported
If the briefing script throws `NameError: name 'sys' is not defined`, add `sys` to the imports:
```python
import caldav, datetime, random, feedparser, os, sys
```
This is required for `sys.stderr` used in error logging inside calendar parsing loops.

### `vobject` not installed
If you see `CRITICAL:root:A vobject instance has been requested, but the vobject library is not installed`, run:
```bash
python3 -m pip install vobject
```
Note: `vobject` is an optional dependency of `caldav`. The calendar parser silently degrades without it, producing `NoneType` errors on `vevent` access.

### `date_search` deprecated
Use `calendar.search(start=..., end=..., event=True, expand=True)` instead of `calendar.date_search()`.

### vobject summary wrapper garbage
When reading `vevent.summary` via `str(getattr(...))`, vobject may emit a wrapper like `<SUMMARY{Backend Final}>` instead of the plain text.

**Fix**: access the `.value` attribute directly:
```python
raw_summary = vevent.summary
if hasattr(raw_summary, 'value'):
    summary = raw_summary.value
else:
    summary = str(raw_summary)
```

### No calendars found
Ensure the app-specific password is correct. Main iCloud passwords are rejected silently.

### SSL / connection errors
iCloud CalDAV requires HTTPS. The `caldav` library handles this automatically.

## Minimal Working Example

```python
import caldav, datetime, os

client = caldav.DAVClient(
    url="https://caldav.icloud.com",
    username=os.environ["APPLE_EMAIL"],
    password=os.environ["APPLE_APP_PASSWORD"],
)
principal = client.principal()
calendars = principal.calendars()

today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
for cal in calendars:
    events = cal.search(start=today, end=tomorrow, event=True, expand=True)
    for event in events:
        vevent = event.vobject_instance.vevent
        print(f"{vevent.summary.value} at {vevent.dtstart.value}")
```

## Multi-Strategy Calendar Fetching (All Calendars, All Events)

Apple iCloud exposes multiple calendars (Personal, Family, Birthdays, Reminders, subscriptions, partner-shared calendars). A single `search()` call may miss events on some calendars or fail on recurring series. Use this fallback pattern:

```python
def get_today_events():
    today = datetime.date.today()
    tomorrow = today + datetime.timedelta(days=1)
    all_events = []
    seen_events = set()

    for calendar in calendars:
        cal_name = calendar.get_display_name() if hasattr(calendar, 'get_display_name') else str(calendar)
        cal_events = []

        # Strategy 1: expand recurring events
        try:
            events = calendar.search(start=today, end=tomorrow, event=True, expand=True)
            cal_events.extend(events)
        except Exception as e:
            print(f"[{cal_name}] expand=True failed: {e}", file=sys.stderr)

        # Strategy 2: raw recurring entries
        if not cal_events:
            try:
                events = calendar.search(start=today, end=tomorrow, event=True)
                cal_events.extend(events)
            except Exception as e:
                print(f"[{cal_name}] expand=False failed: {e}", file=sys.stderr)

        # Strategy 3: list all events and filter client-side
        if not cal_events:
            try:
                events = calendar.events()
                for event in events:
                    try:
                        vevent = event.vobject_instance.vevent
                        dtstart = getattr(vevent, 'dtstart', None)
                        if dtstart and dtstart.value:
                            dt = dtstart.value
                            if isinstance(dt, datetime.datetime):
                                if today <= dt.date() < tomorrow:
                                    cal_events.append(event)
                            elif isinstance(dt, datetime.date):
                                if today <= dt < tomorrow:
                                    cal_events.append(event)
                    except Exception:
                        continue
            except Exception as e:
                print(f"[{cal_name}] events() failed: {e}", file=sys.stderr)

        # Parse and deduplicate
        for event in cal_events:
            try:
                vevent = event.vobject_instance.vevent
                raw_summary = vevent.summary
                summary = raw_summary.value if hasattr(raw_summary, 'value') else str(raw_summary)
                if summary.startswith("<SUMMARY{") and summary.endswith(">"):
                    summary = summary[len("<SUMMARY{"):summary.rfind("}")]

                dtstart = getattr(vevent, "dtstart", None)
                time_str = 'All day'
                event_key = summary
                if dtstart:
                    st = dtstart.value
                    if isinstance(st, datetime.datetime):
                        time_str = st.strftime('%H:%M')
                        event_key = f"{time_str}|{summary}"
                    elif isinstance(st, datetime.date):
                        event_key = f"allday|{summary}"

                if event_key in seen_events:
                    continue
                seen_events.add(event_key)
                all_events.append((time_str, summary))
            except Exception as e:
                print(f"[{cal_name}] Parse error: {e}", file=sys.stderr)

    if not all_events:
        return "*No events scheduled for today.*"

    # Sort: timed first, then all-day
    all_events.sort(key=lambda item: (0 if item[0] != 'All day' else 1, item[0]))
    return "\n".join(f"• {time_str}: {summary}" for time_str, summary in all_events)
```

**Key points:**
- Deduplication via `(time, summary)` hash prevents double-listing when multiple strategies hit the same event.
- Always iterate over ALL calendars returned by `principal.calendars()`, not just the first one.
- Client-side date filtering (Strategy 3) is slower but catches edge cases server-side search misses.
- Log per-calendar errors to `sys.stderr` so cron output shows which calendars had issues without breaking the whole briefing.
- Timed events sort first (chronological), then all-day events. This matches most calendar app behavior.
