"""ICS (iCalendar) feed generation for TaskMate chores (FEAT-10). Pure-ish helpers that turn the chore calendar projection into an RFC 5545 feed a calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view live in ``http_calendar.py``; this module only builds text. """ from __future__ import annotations import hashlib from datetime import date, datetime, timedelta, timezone PRODID = "-//TaskMate//Chores//EN" def _escape(text: str) -> str: """Escape a value per RFC 5545 (backslash, comma, semicolon, newline).""" return ( str(text) .replace("\\", "\\\\") .replace("\n", "\\n") .replace(",", "\\,") .replace(";", "\\;") ) def _fold(line: str) -> str: """Fold a content line to <=75 octets with CRLF + space continuation.""" raw = line.encode("utf-8") if len(raw) <= 75: return line out = [] while len(raw) > 75: # Don't split a multibyte char: back off to a UTF-8 boundary. cut = 75 while cut > 0 and (raw[cut] & 0xC0) == 0x80: cut -= 1 out.append(raw[:cut].decode("utf-8")) raw = raw[cut:] out.append(raw.decode("utf-8")) return "\r\n ".join(out) def _dt_utc(dt: datetime) -> str: return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def _date(d: date) -> str: return d.strftime("%Y%m%d") def make_uid(*parts: str) -> str: digest = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()[:20] return f"{digest}@taskmate" def build_calendar(events: list[dict], now: datetime, name: str = "TaskMate Chores") -> str: """Render ``events`` (dicts: uid, summary, description, start, end, all_day) into an ICS document. ``start``/``end`` are dates (all-day) or aware datetimes.""" stamp = _dt_utc(now) lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", f"PRODID:{PRODID}", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", f"X-WR-CALNAME:{_escape(name)}", ] for ev in events: lines.append("BEGIN:VEVENT") lines.append(f"UID:{ev['uid']}") lines.append(f"DTSTAMP:{stamp}") if ev["all_day"]: lines.append(f"DTSTART;VALUE=DATE:{_date(ev['start'])}") lines.append(f"DTEND;VALUE=DATE:{_date(ev['end'])}") else: lines.append(f"DTSTART:{_dt_utc(ev['start'])}") lines.append(f"DTEND:{_dt_utc(ev['end'])}") lines.append(_fold(f"SUMMARY:{_escape(ev['summary'])}")) if ev.get("description"): lines.append(_fold(f"DESCRIPTION:{_escape(ev['description'])}")) lines.append("END:VEVENT") lines.append("END:VCALENDAR") return "\r\n".join(lines) + "\r\n" def build_chore_events(coordinator, start_day: date, end_day: date) -> list[dict]: """Project each child's scheduled chores over [start_day, end_day] into event dicts.""" from .calendar import _chore_applies_to_child, _chore_description events: list[dict] = [] children = coordinator.storage.get_children() chores = coordinator.storage.get_chores() for child in children: day = start_day while day <= end_day: if not coordinator._is_child_on_vacation(child, day): for chore in chores: if not _chore_applies_to_child(coordinator, chore, child.id, day): continue window = coordinator._time_category_window( getattr(chore, "time_category", "anytime"), day ) summary = f"{chore.name} — {child.name}" desc = _chore_description(chore) if window is None: events.append({ "uid": make_uid(chore.id, child.id, day.isoformat(), "allday"), "summary": summary, "description": desc, "start": day, "end": day + timedelta(days=1), "all_day": True, }) else: start_dt, end_dt = window events.append({ "uid": make_uid(chore.id, child.id, day.isoformat(), "timed"), "summary": summary, "description": desc, "start": _ensure_aware(start_dt), "end": _ensure_aware(end_dt), "all_day": False, }) day += timedelta(days=1) return events def _ensure_aware(dt: datetime) -> datetime: from homeassistant.util import dt as dt_util if dt.tzinfo is None: return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) return dt