diff --git a/README.md b/README.md index 3aecb47..ebe1c49 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ By design, this is **not** a cloud service. Because calendars are already subscr - Reads events from the calendars you specify, in a configurable time window around the current time. The **first** calendar you list is the **primary**; the rest are mirrored against it. -- Mirrors only the events you've **accepted** (tentative, declined, and unanswered are skipped by default). - The set of statuses that count as busy is configurable. +- Mirrors only the events you've **accepted** or that have no response (`unknown`); tentative, declined, and + pending are skipped by default. The set of statuses that count as busy is configurable with `--statuses`. - Drops any event an **exclusion rule** rejects: by title pattern, time, or because the target calendar is already busy over that slot (see [Exclusions](#exclusions)). - Writes one block per mirrored event with a **templated title** @@ -177,6 +177,8 @@ calque --uninstall - **`--lookahead DAYS`** — days after now to mirror (default `60`). - **`--cleanup`** / **`--no-cleanup`** — remove a mirror block once its event is over, instead of keeping it for the lookback window (default off). +- **`--statuses STATUS …`** — participation responses that count as busy and get mirrored, from + `accepted`, `tentative`, `declined`, `pending`, `unknown` (default `accepted unknown`). - **`--exclude-pattern REGEX …`** — replace the default title-exclusion patterns (see [Exclusions](#exclusions)). - **`--exclude-clashes`** / **`--no-exclude-clashes`** — skip a source event that overlaps a genuine @@ -191,8 +193,8 @@ calque --uninstall - **`--uninstall`** — remove the installed `launchd` agent and exit. - **`--logging LEVEL`** — logging level (default `info`; `debug` shows why each event was kept or excluded). -Be careful not to place variadic options (`--mute`, `--exclude-pattern`) immediately before the calendar arguments, -or they will swallow them. +Be careful not to place variadic options (`--mute`, `--exclude-pattern`, `--statuses`) immediately before the +calendar arguments, or they will swallow them. ## Titles @@ -275,5 +277,7 @@ the hub so it can't act as a hub for mirroring. ## Status filtering Your response is read from the event's attendee list. -Only events you've accepted are mirrored by default (the busy statuses are configurable on -`Config`). Events with no attendees (blocks you created yourself) are treated as implicitly accepted. +By default, events you've **accepted** and events with no clear response (`unknown`) are mirrored; tentative, +declined, and pending invitations are skipped. Choose the responses that count as busy with `--statuses`, +e.g. `--statuses accepted tentative`. Events with no attendees (blocks you created yourself) are treated as +implicitly accepted. diff --git a/src/calque/cli.py b/src/calque/cli.py index f1232e9..3fbf20e 100644 --- a/src/calque/cli.py +++ b/src/calque/cli.py @@ -3,13 +3,14 @@ import logging import re import sys -from argparse import Action, ArgumentParser, BooleanOptionalAction, Namespace -from collections.abc import Sequence +from argparse import Action, ArgumentDefaultsHelpFormatter, ArgumentParser, BooleanOptionalAction, Namespace +from collections.abc import Iterable, Sequence from dataclasses import fields from typing import Any, cast -from calque.config import DEFAULT_EXCLUDES, Config +from calque.config import Config from calque.errors import CalqueError +from calque.model import Participation from calque.service import install, uninstall from calque.store import CalendarStore from calque.sync import synchronise @@ -61,11 +62,26 @@ def __call__( setattr(namespace, self.dest, frozenset(cast("Sequence[str]", values))) +def values_string(items: Iterable[Any]) -> str: + """Return a list of the string values of a group of items.""" + return ", ".join(sorted(item.value for item in items)) + + +class DefaultsHelpFormatter(ArgumentDefaultsHelpFormatter): + """Append each option's default to its help unless the help already mentions it.""" + + def _get_help_string(self, action: Action) -> str | None: + """Return the help string for an action, appending the default if not already mentioned.""" + return action.help if action.help and "default: " in action.help else super()._get_help_string(action) + + def parse_arguments(arguments: list[str] | None) -> Namespace: """Build the parser and parse the given command-line arguments.""" + defaults = Config() parser = ArgumentParser( prog="calque", description="Mirror accepted events from one local calendar into another as anonymised busy blocks.", + formatter_class=DefaultsHelpFormatter, ) parser.add_argument( "--list-calendars", @@ -74,14 +90,14 @@ def parse_arguments(arguments: list[str] | None) -> Namespace: ) parser.add_argument( "--title", - default="Busy ({account} calendar)", + default=defaults.title, help="title template used for every mirror block", ) parser.add_argument( "--title-to", nargs=2, action=CollectMapping, - default={}, + default=defaults.title_to, metavar=("ACCOUNT", "TEMPLATE"), help="title template to use when writing into ACCOUNT's calendar, overriding --title; repeatable", ) @@ -89,24 +105,34 @@ def parse_arguments(arguments: list[str] | None) -> Namespace: "--title-from", nargs=2, action=CollectMapping, - default={}, + default=defaults.title_from, metavar=("ACCOUNT", "TEMPLATE"), help="title template for events read from ACCOUNT's calendar, overriding both --title and --title-to; repeatable", ) parser.add_argument( "--lookback", type=int, - default=1, + default=defaults.lookback, help="days before now to mirror; with --cleanup, the window within which finished events are removed", ) - parser.add_argument("--lookahead", type=int, default=60, help="days after now to mirror") + parser.add_argument( + "--lookahead", + type=int, + default=defaults.lookahead, + help="days after now to mirror events", + ) parser.add_argument( "--cleanup", action=BooleanOptionalAction, - default=False, + default=defaults.cleanup, help="remove mirror blocks once their event is over, instead of keeping them through the lookback window", ) - parser.add_argument("--dry-run", action="store_true", help="report the plan without writing anything") + parser.add_argument( + "--dry-run", + action="store_true", + default=defaults.dry_run, + help="report the plan without writing anything", + ) parser.add_argument( "--install", type=int, @@ -120,26 +146,38 @@ def parse_arguments(arguments: list[str] | None) -> Namespace: dest="exclude_patterns", nargs="+", action=CompilePatterns, - default=tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES), + default=defaults.exclude_patterns, metavar="REGEX", - help="Exclude calendar events with titles that match any of these patterns", + help="exclude calendar events with titles that match any of these patterns", + ) + parser.add_argument( + "--statuses", + nargs="+", + type=Participation, + action=CollectSet, + default=defaults.statuses, + metavar="STATUS", + help=( + f"participation responses that count as busy and get mirrored, from {{{values_string(Participation)}}} " + f"(default: {values_string(defaults.statuses)})" + ), ) parser.add_argument( "--exclude-clashes", action=BooleanOptionalAction, - default=True, + default=defaults.exclude_clashes, help="skip a source event when the target is already busy over any part of its slot", ) parser.add_argument( "--exclude-all-day", action=BooleanOptionalAction, - default=True, + default=defaults.exclude_all_day, help="skip all-day events", ) parser.add_argument( "--exclude-out-of-hours", action=BooleanOptionalAction, - default=True, + default=defaults.exclude_out_of_hours, help="skip events that fall entirely outside working hours (Mon-Fri 08:00-18:00)", ) parser.add_argument( @@ -147,9 +185,9 @@ def parse_arguments(arguments: list[str] | None) -> Namespace: dest="muted", nargs="+", action=CollectSet, - default=frozenset(), + default=defaults.muted, metavar="CALENDAR", - help="calendar names that should not be mirrored to; their viewers won't see the mirrored busy blocks", + help="calendar names that should not be mirrored to; their viewers won't see the mirrored busy blocks (default: None)", ) parser.add_argument( "calendars", diff --git a/src/calque/config.py b/src/calque/config.py index 8c16e9c..667bc57 100644 --- a/src/calque/config.py +++ b/src/calque/config.py @@ -40,12 +40,14 @@ class Config: :param dry_run: Whether to report the plan without writing any changes. """ - title: str = "Busy" + title: str = "Busy ({account} calendar)" title_to: dict[str, str] = field(default_factory=dict) title_from: dict[str, str] = field(default_factory=dict) lookback: int = 1 lookahead: int = 60 - statuses: frozenset[Participation] = field(default_factory=lambda: frozenset({Participation.ACCEPTED})) + statuses: frozenset[Participation] = field( + default_factory=lambda: frozenset({Participation.ACCEPTED, Participation.UNKNOWN}), + ) exclude_patterns: tuple[re.Pattern[str], ...] = field( default_factory=lambda: tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 145d864..cff7ec2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ from calque import cli from calque.config import Config from calque.errors import CalendarError, ServiceError +from calque.model import Participation def test_parse_arguments_collects_the_calendars_in_order() -> None: @@ -65,6 +66,28 @@ def test_parse_arguments_defaults_muted_to_empty() -> None: assert cli.parse_arguments(["Personal", "Work"]).muted == frozenset() +def test_parse_arguments_defaults_statuses_to_accepted_and_unknown() -> None: + assert cli.parse_arguments(["Personal", "Work"]).statuses == frozenset( + {Participation.ACCEPTED, Participation.UNKNOWN}, + ) + + +def test_parse_arguments_collects_statuses_into_participation_members() -> None: + # The variadic --statuses must follow the positionals, or it swallows them. + parsed = cli.parse_arguments(["Personal", "Work", "--statuses", "accepted", "tentative"]) + assert parsed.statuses == frozenset({Participation.ACCEPTED, Participation.TENTATIVE}) + + +def test_parse_arguments_rejects_an_unknown_status() -> None: + with pytest.raises(SystemExit): + cli.parse_arguments(["Personal", "Work", "--statuses", "maybe"]) + + +def test_to_config_projects_statuses() -> None: + config = cli.to_config(cli.parse_arguments(["Personal", "Work", "--statuses", "declined"])) + assert config.statuses == frozenset({Participation.DECLINED}) + + def test_to_config_projects_shared_options_and_keeps_defaults_for_the_rest() -> None: options = cli.parse_arguments( [ @@ -85,7 +108,7 @@ def test_to_config_projects_shared_options_and_keeps_defaults_for_the_rest() -> assert config.title_to == {"Work": "{title}"} assert config.lookback == 3 assert config.exclude_all_day is False - assert config.statuses == Config().statuses # a field with no CLI option falls back to its default + assert config.work_start == Config().work_start # a field with no CLI option falls back to its default def test_main_lists_calendars_and_exits(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/test_exclusions.py b/tests/test_exclusions.py index 215f449..99b321b 100644 --- a/tests/test_exclusions.py +++ b/tests/test_exclusions.py @@ -209,15 +209,16 @@ def test_by_participation_excludes_unmirrored_statuses(start: datetime) -> None: def test_rules_excludes_source_events_by_participation(start: datetime) -> None: - exclusions = rules(Config(), (), TARGET) # default statuses mirror ACCEPTED only + exclusions = rules(Config(statuses=frozenset({Participation.ACCEPTED, Participation.UNKNOWN})), (), TARGET) assert excluded(event("tentative", start, participation=Participation.TENTATIVE), exclusions) assert not excluded(event("accepted", start), exclusions) + assert not excluded(event("unknown", start, participation=Participation.UNKNOWN), exclusions) def test_rules_ignores_busy_periods_with_unmirrored_participation(start: datetime) -> None: # A declined block in the target is not genuine busy, so it does not block a source event. declined = event("declined block", start, participation=Participation.DECLINED) - exclusions = rules(Config(), (declined,), TARGET) + exclusions = rules(Config(statuses=frozenset({Participation.ACCEPTED, Participation.UNKNOWN})), (declined,), TARGET) assert not excluded(event("Standup", start), exclusions)