"""Уведомления об изменениях в календаре: новые, удалённые и перенесённые события. При каждом обновлении календаря старая и новая выгрузки сравниваются по UID события. Отдельное вхождение повторяющегося события Яндекс выгружает так: перенос — отдельным VEVENT с тем же UID и RECURRENCE-ID (исходное время), удаление — датой в EXDATE основного события. Яндекс отдаёт всю историю календаря, поэтому прошедшие события не учитываются.""" from datetime import date, datetime, timedelta from html import escape import icalendar import pytz MAX_ITEMS = 10 WEEKDAYS = ("пн", "вт", "ср", "чт", "пт", "сб", "вс") def _norm(value, tz): """Дата остаётся датой, время приводится к часовому поясу календаря""" if isinstance(value, datetime): if value.tzinfo is None: # «плавающее» время — считаем временем календаря value = tz.localize(value) return value.astimezone(tz) return value def _exdates(component, tz): raw = component.get("exdate") if raw is None: return set() lists = raw if isinstance(raw, list) else [raw] return {_norm(item.dt, tz) for lst in lists for item in lst.dts} def snapshot(data, tz): """События выгрузки: {(UID, исходное время вхождения или None): описание}""" events = {} for component in icalendar.Calendar.from_ical(data).walk("VEVENT"): if component.get("dtstart") is None: continue start = _norm(component.decoded("dtstart"), tz) if component.get("dtend") is not None: end = _norm(component.decoded("dtend"), tz) elif component.get("duration") is not None: end = start + component.decoded("duration") else: end = start + timedelta(days=1) if not isinstance(start, datetime) else start rrule = component.get("rrule") until = rrule.get("UNTIL") if rrule else None rid = _norm(component.decoded("recurrence-id"), tz) if component.get("recurrence-id") is not None else None events[(str(component.get("uid", "")), rid)] = { "name": str(component.get("summary", "")).strip() or "Без названия", "start": start, "end": end, "all_day": not isinstance(start, datetime), "recurring": rrule is not None, "until": _norm(until[0], tz) if until else None, "exdates": _exdates(component, tz), } return events def _after(value, now): """Момент value ещё не наступил (для дат — день не закончился)""" if isinstance(value, datetime): return value > now return value >= now.date() def _upcoming(event, now): if event["recurring"]: return event["until"] is None or _after(event["until"], now) end = event["end"] if event["all_day"] and isinstance(end, date) and not isinstance(end, datetime): end -= timedelta(days=1) # DTEND у событий на весь день — следующий день return _after(end, now) def diff(old, new, now): """Список изменений: (вид, событие, прежнее событие или None); вид — added, deleted, moved""" changes = [] for key, event in new.items(): uid, rid = key before = old.get(key) if before is not None: moved = (before["start"], before["end"]) != (event["start"], event["end"]) if moved and (_upcoming(before, now) or _upcoming(event, now)): changes.append(("moved", event, before)) elif event["recurring"]: # удалённые вхождения повторяющегося события for day in sorted(event["exdates"] - before["exdates"], key=str): if (uid, day) not in new and (uid, day) not in old and _after(day, now): changes.append(("deleted", _occurrence(event, day), None)) continue if rid is not None and (uid, None) in old: # вхождение повторяющегося события изменили отдельно — важно, только если сдвинули время if event["start"] != rid and (_after(rid, now) or _after(event["start"], now)): changes.append(("moved", event, _occurrence(old[(uid, None)], rid))) elif _upcoming(event, now): changes.append(("added", event, None)) for key, event in old.items(): if key in new: continue uid, rid = key if rid is not None and (uid, None) in new: master = new[(uid, None)] if rid in master["exdates"]: if _after(event["start"], now): changes.append(("deleted", event, None)) elif event["start"] != rid and (_after(rid, now) or _after(event["start"], now)): changes.append(("moved", _occurrence(master, rid), event)) # вернули на исходное время elif _upcoming(event, now): changes.append(("deleted", event, None)) changes.sort(key=lambda c: _sort_key(c[1]["start"])) return changes def _occurrence(master, start): """Отдельное вхождение повторяющегося события, начинающееся в start""" return {**master, "start": start, "end": start + (master["end"] - master["start"]), "recurring": False, "exdates": set()} def _sort_key(value): if isinstance(value, datetime): return value.astimezone(pytz.utc).replace(tzinfo=None) return datetime.combine(value, datetime.min.time()) def when(event): """Когда событие: «чт 08.10 10:00 — 11:00», «чт 08.10, весь день»""" start, end = event["start"], event["end"] day = f"{WEEKDAYS[start.weekday()]} {start:%d.%m}" if event["all_day"]: last = end - timedelta(days=1) if end > start else start text = f"{day}, весь день" if last == start else f"{day} — {WEEKDAYS[last.weekday()]} {last:%d.%m}, весь день" elif end.date() != start.date(): text = f"{day} {start:%H:%M} — {WEEKDAYS[end.weekday()]} {end:%d.%m %H:%M}" else: text = f"{day} {start:%H:%M} — {end:%H:%M}" return text + (", повторяется" if event["recurring"] else "") def report(old_data, new_data, tz, now=None): """Текст уведомления об изменениях между двумя выгрузками или None, если важных изменений нет""" now = now or datetime.now(tz) changes = diff(snapshot(old_data, tz), snapshot(new_data, tz), now) if not changes: return None parts = [] for kind, event, before in changes[:MAX_ITEMS]: name = escape(event["name"]) if kind == "added": parts.append(f"Новое событие: {name}\n{when(event)}") elif kind == "deleted": parts.append(f"Удалено: {name}\n{when(event)}") else: parts.append(f"Перенесено: {name}\nбыло: {when(before)}\nстало: {when(event)}") if len(changes) > MAX_ITEMS: parts.append(f"…и ещё {len(changes) - MAX_ITEMS}") return "Изменения в календаре\n\n" + "\n\n".join(parts)