File: timeline.py

package info (click to toggle)
python-gcal-sync 7.0.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 416 kB
  • sloc: python: 4,994; sh: 9; makefile: 5
file content (140 lines) | stat: -rw-r--r-- 4,907 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""A Timeline is a set of events on a calendar.

A timeline can be used to iterate over all events, including expanded
recurring events. A timeline also supports methods to scan ranges of events
like returning all events happening today or after a specific date.
"""

from __future__ import annotations

import datetime
import logging
from collections.abc import Generator, Iterable, Iterator
from typing import TypeVar

from ical.iter import (
    LazySortableItem,
    MergedIterable,
    RecurIterable,
    SortableItem,
    SortableItemTimeline,
    SortableItemValue,
    SortedItemIterable,
)
from ical.timespan import Timespan

from .model import DateOrDatetime, Event, EventStatusEnum, SyntheticEventId

__all__ = ["Timeline"]

_LOGGER = logging.getLogger(__name__)

T = TypeVar("T")


class Timeline(SortableItemTimeline[Event]):
    """A set of events on a calendar.

    A timeline is created by the local sync API and not instantiated directly.
    """

    def __init__(self, iterable: Iterable[SortableItem[Timespan, Event]]) -> None:
        super().__init__(iterable)


class RecurAdapter:
    """An adapter that expands an Event instance for a recurrence rule.

    This adapter is given an event, then invoked with a specific date/time instance
    that the event occurs on due to a recurrence rule. The event is copied with
    necessary updated fields to act as a flattened instance of the event.
    """

    def __init__(self, event: Event):
        """Initialize the RecurAdapter."""
        self._event = event
        self._event_duration = event.computed_duration

    def get(
        self, dtstart: datetime.datetime | datetime.date
    ) -> SortableItem[Timespan, Event]:
        """Return a lazy sortable item."""

        def build() -> Event:
            if not self._event.id:
                raise ValueError("Expected event to have event id")
            event_id = SyntheticEventId.of(self._event.id, dtstart)
            return self._event.copy(
                deep=True,
                update={
                    "start": DateOrDatetime.parse(dtstart),
                    "end": DateOrDatetime.parse(dtstart + self._event_duration),
                    "id": event_id.event_id,
                    "original_start_time": self._event.start,
                    "recurring_event_id": self._event.id,
                },
            )

        return LazySortableItem(
            Timespan.of(dtstart, dtstart + self._event_duration), build
        )


class FilteredIterable(Iterable[T]):
    """An iterable that excludes emits values except those excluded."""

    def __init__(self, func: Iterable[T], exclude: set[T] | None) -> None:
        self._func = func
        self._exclude = exclude

    def __iter__(self) -> Iterator[T]:
        """Return an iterator filtered by the exclusion set."""
        for value in self._func:
            if self._exclude is not None and value in self._exclude:
                continue
            yield value


def calendar_timeline(
    events: list[Event], tzinfo: datetime.tzinfo = datetime.timezone.utc
) -> Timeline:
    """Create a timeline for events on a calendar, including recurrence."""
    normal_events: list[Event] = []
    recurring: list[Event] = []
    recurring_skip: dict[str, set[datetime.date | datetime.datetime]] = {}
    for event in events:
        if event.recurring_event_id and event.original_start_time:
            # The API returned a one-off instance of a recurring event. Keep track
            # of the original start time which is used to filter out from the
            # recurrence. The one-off is handled below.
            if event.recurring_event_id in recurring_skip:
                recurring_skip[event.recurring_event_id].add(
                    event.original_start_time.value
                )
            else:
                recurring_skip[event.recurring_event_id] = set(
                    [event.original_start_time.value]
                )

        if event.status == EventStatusEnum.CANCELLED:
            continue
        if event.recurrence:
            recurring.append(event)
        else:
            normal_events.append(event)

    def sortable_items() -> Generator[SortableItem[Timespan, Event], None, None]:
        nonlocal normal_events
        for event in normal_events:
            if event.status == EventStatusEnum.CANCELLED:
                continue
            yield SortableItemValue(event.timespan_of(tzinfo), event)

    iters: list[Iterable[SortableItem[Timespan, Event]]] = []
    iters.append(SortedItemIterable(sortable_items, tzinfo))
    for event in recurring:
        value_iter: Iterable[datetime.date | datetime.datetime] = event.rrule
        value_iter = FilteredIterable(value_iter, recurring_skip.get(event.id or ""))
        iters.append(RecurIterable(RecurAdapter(event).get, value_iter))

    return Timeline(MergedIterable(iters))