1
0
Fork 0
mirror of https://github.com/EDeev/y.calendarkin.git synced 2026-10-08 00:39:27 +03:00
y.calendarkin/code/changes.py
Egor Deev 4055242ddf
Уведомления о новых, удалённых и перенесённых событиях (#5)
При обновлении календаря (раз в 13 минут) старая выгрузка сравнивается с новой по UID события.
Бот присылает одно сообщение со списком изменений: новое событие, удалено, перенесено (было/стало).
Учитываются и отдельные вхождения повторяющихся событий — перенос (RECURRENCE-ID) и удаление
(EXDATE). Прошедшие события пропускаются: Яндекс выгружает всю историю календаря. В сообщении —
не больше 10 изменений, остальные одной строкой «…и ещё N».

Команда /changes включает и выключает эти уведомления; по умолчанию включены у всех, колонка
alarms.changes добавляется при запуске. При подписке и замене ссылки сравнения нет.

Closes #2
2026-10-07 16:27:58 +03:00

165 lines
7.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Уведомления об изменениях в календаре: новые, удалённые и перенесённые события.
При каждом обновлении календаря старая и новая выгрузки сравниваются по 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"<b>Новое событие:</b> {name}\n{when(event)}")
elif kind == "deleted":
parts.append(f"<b>Удалено:</b> {name}\n<s>{when(event)}</s>")
else:
parts.append(f"<b>Перенесено:</b> {name}\nбыло: {when(before)}\nстало: {when(event)}")
if len(changes) > MAX_ITEMS:
parts.append(f"…и ещё {len(changes) - MAX_ITEMS}")
return "<b>Изменения в календаре</b>\n\n" + "\n\n".join(parts)