New apps Added
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user