calque 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calque/__init__.py +3 -0
- calque/cli.py +200 -0
- calque/config.py +69 -0
- calque/errors.py +33 -0
- calque/exclusions.py +186 -0
- calque/model.py +158 -0
- calque/py.typed +0 -0
- calque/service.py +72 -0
- calque/store.py +219 -0
- calque/sync.py +77 -0
- calque-0.1.0.dist-info/METADATA +275 -0
- calque-0.1.0.dist-info/RECORD +15 -0
- calque-0.1.0.dist-info/WHEEL +4 -0
- calque-0.1.0.dist-info/entry_points.txt +2 -0
- calque-0.1.0.dist-info/licenses/LICENSE +21 -0
calque/__init__.py
ADDED
calque/cli.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Command-line entry point: parse options into a configuration and drive a synchronisation run."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from argparse import Action, ArgumentParser, BooleanOptionalAction, Namespace
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import fields
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from calque.config import DEFAULT_EXCLUDES, Config
|
|
12
|
+
from calque.errors import CalqueError
|
|
13
|
+
from calque.service import install, uninstall
|
|
14
|
+
from calque.store import CalendarStore
|
|
15
|
+
from calque.sync import synchronise
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CompilePatterns(Action):
|
|
19
|
+
"""Compile the given regular expressions and store them as the immutable exclusion-pattern set."""
|
|
20
|
+
|
|
21
|
+
def __call__(
|
|
22
|
+
self,
|
|
23
|
+
parser: ArgumentParser, # noqa: ARG002
|
|
24
|
+
namespace: Namespace,
|
|
25
|
+
values: str | Sequence[Any] | None,
|
|
26
|
+
option_string: str | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Compile every pattern and store the resulting tuple on the namespace."""
|
|
29
|
+
setattr(namespace, self.dest, tuple(re.compile(pattern) for pattern in cast("Sequence[str]", values)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CollectMapping(Action):
|
|
33
|
+
"""Collect one or more ``--option-for KEY VALUE`` pairs into a mapping.
|
|
34
|
+
|
|
35
|
+
Requires that the destination field is defaulted to a mutable mapping type, which it updates in-place with each pair.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __call__(
|
|
39
|
+
self,
|
|
40
|
+
parser: ArgumentParser, # noqa: ARG002
|
|
41
|
+
namespace: Namespace,
|
|
42
|
+
values: str | Sequence[Any] | None,
|
|
43
|
+
option_string: str | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Store one parsed key/value pair into the mapping."""
|
|
46
|
+
key, value = cast("Sequence[str]", values)
|
|
47
|
+
getattr(namespace, self.dest)[key] = value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CollectSet(Action):
|
|
51
|
+
"""Collect the given values into an immutable set on the namespace."""
|
|
52
|
+
|
|
53
|
+
def __call__(
|
|
54
|
+
self,
|
|
55
|
+
parser: ArgumentParser, # noqa: ARG002
|
|
56
|
+
namespace: Namespace,
|
|
57
|
+
values: str | Sequence[Any] | None,
|
|
58
|
+
option_string: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Store the values as a frozenset on the namespace."""
|
|
61
|
+
setattr(namespace, self.dest, frozenset(cast("Sequence[str]", values)))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_arguments(arguments: list[str] | None) -> Namespace:
|
|
65
|
+
"""Build the parser and parse the given command-line arguments."""
|
|
66
|
+
parser = ArgumentParser(
|
|
67
|
+
prog="calque",
|
|
68
|
+
description="Mirror accepted events from one local calendar into another as anonymised busy blocks.",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--list-calendars",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="list all local calendar titles (qualified account.calendar names) and exit",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--title",
|
|
77
|
+
default="Busy ({account} calendar)",
|
|
78
|
+
help="title template used for every mirror block",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--title-to",
|
|
82
|
+
nargs=2,
|
|
83
|
+
action=CollectMapping,
|
|
84
|
+
default={},
|
|
85
|
+
metavar=("ACCOUNT", "TEMPLATE"),
|
|
86
|
+
help="title template to use when writing into ACCOUNT's calendar, overriding --title; repeatable",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--title-from",
|
|
90
|
+
nargs=2,
|
|
91
|
+
action=CollectMapping,
|
|
92
|
+
default={},
|
|
93
|
+
metavar=("ACCOUNT", "TEMPLATE"),
|
|
94
|
+
help="title template for events read from ACCOUNT's calendar, overriding both --title and --title-to; repeatable",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--lookback",
|
|
98
|
+
type=int,
|
|
99
|
+
default=1,
|
|
100
|
+
help="days before now to mirror; with --cleanup, the window within which finished events are removed",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument("--lookahead", type=int, default=60, help="days after now to mirror")
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--cleanup",
|
|
105
|
+
action=BooleanOptionalAction,
|
|
106
|
+
default=False,
|
|
107
|
+
help="remove mirror blocks once their event is over, instead of keeping them through the lookback window",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument("--dry-run", action="store_true", help="report the plan without writing anything")
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--install",
|
|
112
|
+
type=int,
|
|
113
|
+
metavar="SECONDS",
|
|
114
|
+
help="install a launchd agent that runs this same command every SECONDS seconds, then exit",
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument("--uninstall", action="store_true", help="remove the installed launchd agent and exit")
|
|
117
|
+
parser.add_argument("--logging", default="info", help="set the logging level")
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--exclude-pattern",
|
|
120
|
+
dest="exclude_patterns",
|
|
121
|
+
nargs="+",
|
|
122
|
+
action=CompilePatterns,
|
|
123
|
+
default=tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES),
|
|
124
|
+
metavar="REGEX",
|
|
125
|
+
help="Exclude calendar events with titles that match any of these patterns",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--exclude-clashes",
|
|
129
|
+
action=BooleanOptionalAction,
|
|
130
|
+
default=True,
|
|
131
|
+
help="skip a source event when the target is already busy over any part of its slot",
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--exclude-all-day",
|
|
135
|
+
action=BooleanOptionalAction,
|
|
136
|
+
default=True,
|
|
137
|
+
help="skip all-day events",
|
|
138
|
+
)
|
|
139
|
+
parser.add_argument(
|
|
140
|
+
"--exclude-out-of-hours",
|
|
141
|
+
action=BooleanOptionalAction,
|
|
142
|
+
default=True,
|
|
143
|
+
help="skip events that fall entirely outside working hours (Mon-Fri 08:00-18:00)",
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--mute",
|
|
147
|
+
dest="muted",
|
|
148
|
+
nargs="+",
|
|
149
|
+
action=CollectSet,
|
|
150
|
+
default=frozenset(),
|
|
151
|
+
metavar="CALENDAR",
|
|
152
|
+
help="calendar names that should not be mirrored to; their viewers won't see the mirrored busy blocks",
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"calendars",
|
|
156
|
+
nargs="*",
|
|
157
|
+
help="calendars to mirror; the first is the primary calendar that every auxiliary is mirrored into and back out from",
|
|
158
|
+
)
|
|
159
|
+
options = parser.parse_args(arguments)
|
|
160
|
+
# --list-calendars and --uninstall stand alone; a sync or an install needs a primary and at least one auxiliary.
|
|
161
|
+
if len(options.calendars) <= 1 and not (options.list_calendars or options.uninstall):
|
|
162
|
+
parser.error("at least two calendars are required: a primary and some auxiliary calendars to mirror with")
|
|
163
|
+
return options
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def to_config(options: Namespace) -> Config:
|
|
167
|
+
"""Project the parsed options onto the configuration fields they share a name with.
|
|
168
|
+
|
|
169
|
+
The parser is responsible for giving every shared option its final type, so this mapping
|
|
170
|
+
stays uniform: a new option needs only to match on name.
|
|
171
|
+
"""
|
|
172
|
+
return Config(
|
|
173
|
+
**{field.name: getattr(options, field.name) for field in fields(Config) if hasattr(options, field.name)}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(arguments: list[str] | None = None) -> int:
|
|
178
|
+
"""Dispatch one calque invocation — list, install, uninstall, or sync — and return a process exit code."""
|
|
179
|
+
arguments = sys.argv[1:] if arguments is None else arguments
|
|
180
|
+
options = parse_arguments(arguments)
|
|
181
|
+
logging.basicConfig(level=options.logging.upper(), format="%(levelname)s: %(message)s")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
if options.list_calendars:
|
|
185
|
+
for name in CalendarStore().qualified_names():
|
|
186
|
+
print(name)
|
|
187
|
+
elif options.install is not None:
|
|
188
|
+
install(arguments, options.install)
|
|
189
|
+
elif options.uninstall:
|
|
190
|
+
uninstall()
|
|
191
|
+
else:
|
|
192
|
+
synchronise(to_config(options), options.calendars)
|
|
193
|
+
except CalqueError as exception:
|
|
194
|
+
logging.error("calque failed: %s", exception)
|
|
195
|
+
return 1
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
raise SystemExit(main())
|
calque/config.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Runtime configuration shared across every mirror direction."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import time
|
|
6
|
+
|
|
7
|
+
from calque.model import Participation
|
|
8
|
+
from calque.store import Calendar
|
|
9
|
+
|
|
10
|
+
# Titles that signal availability rather than a genuine commitment: a bare "Working"
|
|
11
|
+
# status block, and any annual-leave marker (the "A/L" shorthand).
|
|
12
|
+
DEFAULT_EXCLUDES = [r"^Working$", r"\bA/L\b"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Config:
|
|
17
|
+
"""Settings governing how events are mirrored, independent of direction.
|
|
18
|
+
|
|
19
|
+
:param title: The default title template for mirror blocks; ``{field}`` placeholders are
|
|
20
|
+
filled from the source event (e.g. ``{account}``, ``{title}``).
|
|
21
|
+
:param title_to: Title templates keyed by the fully-qualified name of the
|
|
22
|
+
calendar being written into, overriding ``title`` for that target.
|
|
23
|
+
:param title_from: Title templates keyed by the fully-qualified name of the calendar an event was
|
|
24
|
+
read from, overriding ``title`` and any ``title_to`` entry for that source.
|
|
25
|
+
:param lookback: Days before now to keep mirrored, so recently-passed events stay tidy. When
|
|
26
|
+
``cleanup`` is set, this instead bounds the window within which finished events are removed.
|
|
27
|
+
:param lookahead: Days after now to mirror.
|
|
28
|
+
:param statuses: The participation responses that count as "busy" and get mirrored.
|
|
29
|
+
:param exclude_patterns: Patterns whose match against a source title drops that event from the mirror.
|
|
30
|
+
:param exclude_clashes: Whether to drop a source event that overlaps an existing target event.
|
|
31
|
+
:param exclude_all_day: Whether to drop all-day events.
|
|
32
|
+
:param exclude_out_of_hours: Whether to drop events that fall entirely outside working hours.
|
|
33
|
+
:param work_days: Weekdays (Monday is 0) whose working hours are mirrored.
|
|
34
|
+
:param work_start: Start of the daily working-hours window, in local time.
|
|
35
|
+
:param work_end: End of the daily working-hours window, in local time.
|
|
36
|
+
:param muted: Names of calendars that should not be mirrored to, so their viewers never see the
|
|
37
|
+
mirrored busy blocks; the calendar is still read as a source and mirrored into the others.
|
|
38
|
+
:param cleanup: Whether to remove a mirror block once its event is over (end time has passed),
|
|
39
|
+
rather than keeping it for the ``lookback`` window.
|
|
40
|
+
:param dry_run: Whether to report the plan without writing any changes.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
title: str = "Busy"
|
|
44
|
+
title_to: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
title_from: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
lookback: int = 1
|
|
47
|
+
lookahead: int = 60
|
|
48
|
+
statuses: frozenset[Participation] = field(default_factory=lambda: frozenset({Participation.ACCEPTED}))
|
|
49
|
+
exclude_patterns: tuple[re.Pattern[str], ...] = field(
|
|
50
|
+
default_factory=lambda: tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES),
|
|
51
|
+
)
|
|
52
|
+
exclude_clashes: bool = True
|
|
53
|
+
exclude_all_day: bool = True
|
|
54
|
+
exclude_out_of_hours: bool = True
|
|
55
|
+
work_days: frozenset[int] = frozenset({0, 1, 2, 3, 4})
|
|
56
|
+
work_start: time = time(8)
|
|
57
|
+
work_end: time = time(18)
|
|
58
|
+
muted: frozenset[str] = frozenset()
|
|
59
|
+
cleanup: bool = False
|
|
60
|
+
dry_run: bool = False
|
|
61
|
+
|
|
62
|
+
def title_for_calendar(self, source: Calendar, target: Calendar) -> str:
|
|
63
|
+
"""Return the title template for mirroring from ``source`` into ``target``, or the default.
|
|
64
|
+
|
|
65
|
+
A source override (``title_from``) wins over a target override (``title_to``), so a calendar
|
|
66
|
+
whose events should always be opaque can be pinned once wherever they land. Both are keyed on the
|
|
67
|
+
fully-qualified name (e.g. ``ClientCompany.Calendar``, as shown by ``--list-calendars``).
|
|
68
|
+
"""
|
|
69
|
+
return self.title_from.get(source.qualified, self.title_to.get(target.qualified, self.title))
|
calque/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Exception hierarchy for the mirror. Each composes its own message from the failing value."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CalqueError(Exception):
|
|
5
|
+
"""Base for every error raised by calque."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccessError(CalqueError):
|
|
9
|
+
"""Calendar access was denied or could not be resolved in time."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status: object) -> None:
|
|
12
|
+
super().__init__(f"calendar access not granted (authorisation status {status})")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CalendarError(CalqueError):
|
|
16
|
+
"""No calendar with the requested title exists in the local store."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, title: str) -> None:
|
|
19
|
+
super().__init__(f"no calendar titled {title!r} found in the local store")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WriteError(CalqueError):
|
|
23
|
+
"""EventKit refused to save or remove a mirror block."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, detail: object) -> None:
|
|
26
|
+
super().__init__(f"EventKit write failed: {detail}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ServiceError(CalqueError):
|
|
30
|
+
"""Installing or removing the launchd agent failed."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, detail: object) -> None:
|
|
33
|
+
super().__init__(f"launchctl failed: {detail}")
|
calque/exclusions.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Composable exclusion rules that keep selected source events out of the mirror.
|
|
2
|
+
|
|
3
|
+
An :data:`Exclusion` is a predicate over an event, true when that event should be dropped.
|
|
4
|
+
Builders turn configuration and target-calendar context into rules, :func:`rules` assembles
|
|
5
|
+
the active ones, and :func:`excluded` runs them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable, Container, Iterable, Iterator
|
|
11
|
+
from datetime import UTC, date, datetime, time, timedelta
|
|
12
|
+
|
|
13
|
+
from calque.config import Config
|
|
14
|
+
from calque.model import Event, Participation, untag
|
|
15
|
+
|
|
16
|
+
Exclusion = Callable[[Event], bool]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dates_spanned(start: datetime, end: datetime) -> Iterator[date]:
|
|
20
|
+
"""Yield each local calendar date an event touches, from its start through its end."""
|
|
21
|
+
day = start.date()
|
|
22
|
+
while day <= end.date():
|
|
23
|
+
yield day
|
|
24
|
+
day += timedelta(days=1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def by_title(patterns: tuple[re.Pattern[str], ...]) -> Exclusion:
|
|
28
|
+
"""Build a rule excluding events whose title matches any of the given patterns."""
|
|
29
|
+
|
|
30
|
+
def rule(event: Event) -> bool:
|
|
31
|
+
"""Test the event's title against every configured pattern."""
|
|
32
|
+
for pattern in patterns:
|
|
33
|
+
if pattern.search(event.title):
|
|
34
|
+
logging.debug("excluding %r by title (%r)", event.title, pattern.pattern)
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
return rule
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def by_clash(events: Iterable[Event]) -> Exclusion:
|
|
42
|
+
"""Build a rule excluding events that overlap any interval already busy in the target."""
|
|
43
|
+
busy = tuple(event.window for event in events)
|
|
44
|
+
|
|
45
|
+
def rule(event: Event) -> bool:
|
|
46
|
+
"""Half-open overlap test of the event against each busy interval."""
|
|
47
|
+
for interval in busy:
|
|
48
|
+
if event.start < interval.end and interval.start < event.end:
|
|
49
|
+
logging.debug("excluding %r due to clash with %s", event.title, interval)
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
return rule
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def by_passed() -> Exclusion:
|
|
57
|
+
"""Build a rule excluding events whose end time has already passed.
|
|
58
|
+
|
|
59
|
+
Enabled by cleanup: the cut-off is the moment the rule is built — cheap to read and captured by
|
|
60
|
+
the returned closure — so a finished event drops out of the desired set and reconciliation
|
|
61
|
+
deletes its now-stale mirror block instead of keeping it through the lookback window.
|
|
62
|
+
"""
|
|
63
|
+
now = datetime.now(UTC)
|
|
64
|
+
|
|
65
|
+
def rule(event: Event) -> bool:
|
|
66
|
+
"""True when the event has already ended."""
|
|
67
|
+
result = event.end < now
|
|
68
|
+
if result:
|
|
69
|
+
logging.debug("excluding %r as already ended", event.title)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
return rule
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def by_origin(target: str) -> Exclusion:
|
|
76
|
+
"""Build a rule excluding our own mirror blocks that we previously copied from ``target``.
|
|
77
|
+
|
|
78
|
+
A mirror is dropped only when it would return to the calendar it came from — which would become a
|
|
79
|
+
mirror of a mirror (this would otherwise be caused by a deleted event).
|
|
80
|
+
A mirror in the primary, sourced from one calendar, still propagates onward to the others.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def rule(event: Event) -> bool:
|
|
84
|
+
"""True when the event is our mirror block originating from the target calendar."""
|
|
85
|
+
marker = untag(event.notes)
|
|
86
|
+
result = marker is not None and marker.origin == target
|
|
87
|
+
if result:
|
|
88
|
+
logging.debug("excluding %r as our own mirror returning to %s", event.title, target)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
return rule
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_all_day(event: Event) -> bool:
|
|
95
|
+
"""Whether the event is an all-day event, which would otherwise block the whole day."""
|
|
96
|
+
result = event.all_day
|
|
97
|
+
if result:
|
|
98
|
+
logging.debug("excluding %r by all-day", event.title)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def by_participation(statuses: frozenset[Participation]) -> Exclusion:
|
|
103
|
+
"""Build a rule excluding events whose participation is not among the mirrored statuses."""
|
|
104
|
+
|
|
105
|
+
def rule(event: Event) -> bool:
|
|
106
|
+
"""True when the event's participation is not one we mirror."""
|
|
107
|
+
result = event.participation not in statuses
|
|
108
|
+
if result:
|
|
109
|
+
logging.debug("excluding %r by participation (%s)", event.title, event.participation.value)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
return rule
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def by_hours(days: Container[int], opening: time, closing: time) -> Exclusion:
|
|
116
|
+
"""Build a rule excluding events that fall entirely outside the working-hours window.
|
|
117
|
+
|
|
118
|
+
Hours are compared in local time. An event is kept if it overlaps the window on any
|
|
119
|
+
working day it touches, so an event straddling the edge (e.g. 17:00-19:00) still mirrors.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def rule(event: Event) -> bool:
|
|
123
|
+
"""True when no working-hours interval on any day the event spans overlaps it."""
|
|
124
|
+
start = event.start.astimezone()
|
|
125
|
+
end = event.end.astimezone()
|
|
126
|
+
result = not any(
|
|
127
|
+
start < datetime.combine(day, closing, start.tzinfo) and datetime.combine(day, opening, start.tzinfo) < end
|
|
128
|
+
for day in dates_spanned(start, end)
|
|
129
|
+
if day.weekday() in days
|
|
130
|
+
)
|
|
131
|
+
if result:
|
|
132
|
+
logging.debug("excluding %r by hours (%s-%s)", event.title, opening, closing)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
return rule
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def rules(config: Config, events: Iterable[Event], target: str) -> tuple[Exclusion, ...]:
|
|
139
|
+
"""Return the active exclusion rule chain for mirroring into ``target`` given its busy ``events``.
|
|
140
|
+
|
|
141
|
+
The full exclusion chain:
|
|
142
|
+
- intrinsic rules:
|
|
143
|
+
- participation status
|
|
144
|
+
- title patterns
|
|
145
|
+
- all-day
|
|
146
|
+
- out-of-hours
|
|
147
|
+
- finished events, when cleanup is enabled
|
|
148
|
+
- our own mirror blocks returning to ``target``
|
|
149
|
+
- clash against target busy periods - which itself filters using the intrinsic rules
|
|
150
|
+
|
|
151
|
+
A mirror block is excluded only when its origin is ``target``, so a block written by a previous sync is never
|
|
152
|
+
returned to its source (which would chain into a mirror of a mirror) yet still propagates to the other
|
|
153
|
+
calendars. Genuinely busy periods are the target events the intrinsic rules don't themselves exclude, so a
|
|
154
|
+
focus block, all-day, out-of-hours or unaccepted event never blocks a source event.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def build() -> Iterable[Exclusion]:
|
|
158
|
+
yield by_participation(config.statuses)
|
|
159
|
+
if config.exclude_patterns:
|
|
160
|
+
yield by_title(config.exclude_patterns)
|
|
161
|
+
if config.exclude_all_day:
|
|
162
|
+
yield is_all_day
|
|
163
|
+
if config.exclude_out_of_hours:
|
|
164
|
+
yield by_hours(config.work_days, config.work_start, config.work_end)
|
|
165
|
+
if config.cleanup:
|
|
166
|
+
yield by_passed()
|
|
167
|
+
|
|
168
|
+
intrinsic = tuple(build())
|
|
169
|
+
exclusions = (*intrinsic, by_origin(target))
|
|
170
|
+
if not config.exclude_clashes:
|
|
171
|
+
return exclusions
|
|
172
|
+
# The clash rule takes the target events, which we filter to the genuinely busy periods using the intrinsic rules.
|
|
173
|
+
# Then, when it is used, it excludes any source event that overlaps the busy periods.
|
|
174
|
+
return (*exclusions, by_clash(included(events, intrinsic)))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def excluded(event: Event, exclusions: Iterable[Exclusion]) -> bool:
|
|
178
|
+
"""Predicate: whether any rule in the chain excludes the event."""
|
|
179
|
+
return any(rule(event) for rule in exclusions)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def included(events: Iterable[Event], exclusions: Iterable[Exclusion]) -> Iterator[Event]:
|
|
183
|
+
"""Filter: yield only the events that no exclusion rule rejects."""
|
|
184
|
+
for event in events:
|
|
185
|
+
if not excluded(event, exclusions):
|
|
186
|
+
yield event
|
calque/model.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Domain model for the mirror: events, anonymised busy blocks, and the reconciliation plan."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
MARKER = "⟦calque⟧"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Participation(Enum):
|
|
13
|
+
"""The current user's response to an event invitation."""
|
|
14
|
+
|
|
15
|
+
ACCEPTED = "accepted"
|
|
16
|
+
TENTATIVE = "tentative"
|
|
17
|
+
DECLINED = "declined"
|
|
18
|
+
PENDING = "pending"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Source(Protocol):
|
|
23
|
+
"""The slice of an EventKit ``EKSource`` we read: the account a calendar belongs to.
|
|
24
|
+
|
|
25
|
+
A narrowing of PyObjC's untyped objects; ``store.Calendar.source`` returns one.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def title(self) -> str:
|
|
29
|
+
"""The account name (e.g. the Google or Exchange account behind the calendar)."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Window:
|
|
35
|
+
"""A half-open time range in UTC — a query range or an interval occupied by an event."""
|
|
36
|
+
|
|
37
|
+
start: datetime
|
|
38
|
+
end: datetime
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
"""A readable local-time rendering of the range, dropping seconds and the timezone."""
|
|
42
|
+
start = self.start.astimezone()
|
|
43
|
+
end = self.end.astimezone()
|
|
44
|
+
until = f"{end:%H:%M}" if start.date() == end.date() else f"{end:%a %Y-%m-%d %H:%M}"
|
|
45
|
+
return f"{start:%a %Y-%m-%d %H:%M} to {until}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Event:
|
|
50
|
+
"""A source-calendar event reduced to the fields the mirror cares about."""
|
|
51
|
+
|
|
52
|
+
identifier: str
|
|
53
|
+
title: str
|
|
54
|
+
account: str
|
|
55
|
+
window: Window
|
|
56
|
+
participation: Participation
|
|
57
|
+
all_day: bool
|
|
58
|
+
notes: str | None = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def start(self) -> datetime:
|
|
62
|
+
"""The event's start, in UTC."""
|
|
63
|
+
return self.window.start
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def end(self) -> datetime:
|
|
67
|
+
"""The event's end, in UTC."""
|
|
68
|
+
return self.window.end
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
"""A human-readable representation of the event for logging and diagnostics."""
|
|
72
|
+
return f"title={self.title!r} {self.window}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class Mirror:
|
|
77
|
+
"""An anonymised busy block in the target calendar, linked to its source by identifier."""
|
|
78
|
+
|
|
79
|
+
source: str
|
|
80
|
+
window: Window
|
|
81
|
+
title: str
|
|
82
|
+
# Excluded from equality:
|
|
83
|
+
original: str | None = field(default=None, compare=False)
|
|
84
|
+
# The calendar this block was copied from, stored in the tag so a mirror is never returned to its
|
|
85
|
+
# source. Excluded from equality: it is fixed per direction and absent on blocks read back for diffing.
|
|
86
|
+
origin: str = field(default="", compare=False)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def start(self) -> datetime:
|
|
90
|
+
"""The block's start, in UTC."""
|
|
91
|
+
return self.window.start
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def end(self) -> datetime:
|
|
95
|
+
"""The block's end, in UTC."""
|
|
96
|
+
return self.window.end
|
|
97
|
+
|
|
98
|
+
def __str__(self) -> str:
|
|
99
|
+
"""A human-readable representation of the mirror, in local time, for diagnostics."""
|
|
100
|
+
return f"title={self.title!r} original={self.original!r} window={self.window} source={self.source!r}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class Plan:
|
|
105
|
+
"""The reconciliation outcome: blocks to create, retime, and remove in the target."""
|
|
106
|
+
|
|
107
|
+
create: tuple[Mirror, ...]
|
|
108
|
+
update: tuple[Mirror, ...]
|
|
109
|
+
delete: tuple[Mirror, ...]
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def empty(self) -> bool:
|
|
113
|
+
"""Whether the plan would change nothing."""
|
|
114
|
+
return not (self.create or self.update or self.delete)
|
|
115
|
+
|
|
116
|
+
def __str__(self) -> str:
|
|
117
|
+
"""A human-readable summary of the plan's contents for logging and diagnostics."""
|
|
118
|
+
return f"create={len(self.create)} update={len(self.update)} delete={len(self.delete)}"
|
|
119
|
+
|
|
120
|
+
def log(self) -> None:
|
|
121
|
+
"""Log the plan's contents in a human-readable form."""
|
|
122
|
+
logging.info("create:")
|
|
123
|
+
for event in self.create:
|
|
124
|
+
logging.info(" %s", event)
|
|
125
|
+
logging.info("update:")
|
|
126
|
+
for event in self.update:
|
|
127
|
+
logging.info(" %s", event)
|
|
128
|
+
logging.info("delete:")
|
|
129
|
+
for event in self.delete:
|
|
130
|
+
logging.info(" %s", event)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class Tag:
|
|
135
|
+
"""The provenance encoded on a mirror block: the calendar it was copied from and that source's event id."""
|
|
136
|
+
|
|
137
|
+
origin: str
|
|
138
|
+
identifier: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def tag(origin: str, identifier: str) -> str:
|
|
142
|
+
"""Encode a mirror block's origin calendar and source event id into the marker stored on its notes.
|
|
143
|
+
|
|
144
|
+
The fields are space-separated: the identifier never contains a space, so it is the first token,
|
|
145
|
+
and the origin (which may) is the remainder. A space survives any normalisation that some
|
|
146
|
+
calendar backends apply, where a tab or newline does not.
|
|
147
|
+
"""
|
|
148
|
+
return f"{MARKER} {identifier} {origin}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def untag(notes: str | None) -> Tag | None:
|
|
152
|
+
"""Recover the origin and source id from a mirror block's notes, or ``None`` if not one of ours."""
|
|
153
|
+
if not notes or not notes.startswith(MARKER):
|
|
154
|
+
return None
|
|
155
|
+
identifier, _, origin = notes.removeprefix(MARKER).strip().partition(" ")
|
|
156
|
+
if not identifier:
|
|
157
|
+
return None
|
|
158
|
+
return Tag(origin=origin, identifier=identifier)
|
calque/py.typed
ADDED
|
File without changes
|