Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h

## [Unreleased]

### Added

* `plann freebusy-sync`: keeps a copy of the busy time from one set of calendars in another calendar, holding only the time and a fixed summary. Copies are moved and deleted as the source changes, so it can run from a timer in both directions between a work and a private calendar. See the user guide.

### Deprecated

* `select --freebusyhack`, superseded by `freebusy-sync`. It keeps the UID of the original, and it leaves the summary, description and attendees of recurrence exceptions in place.

### Changed

* Shell tab completion scripts (bash, zsh, fish) now ship with the package. They work without shell configuration for system-wide installs (and for bash user/venv installs with bash-completion 2.12+); see the README for other setups. The `make install-completion` targets are gone.
Expand Down
14 changes: 14 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Those commands are made mostly for making `plann` more convenient to use for the

* agenda - list up some of the upcoming events plus some of the upcoming tasks
* interactive manage-tasks - go through your tasks and make suggestions
* freebusy-sync - copy busy time between calendars, see below

Note that those commands are mostly tested against Xandikos (which plann's functional tests run on) and earlier DAViCal (see the [`docs/CALENDAR_SERVER_RECOMMENDATIONS.md`](docs/CALENDAR_SERVER_RECOMMENDATIONS.md) file).

Expand Down Expand Up @@ -156,6 +157,19 @@ plann select --todo --category computer-work --start=2022-04-04 --end=2022-05-05
plann select --todo --category computer-work --overdue edit --postpone=5d
```

## Sharing busy time between calendars

`freebusy-sync` copies every busy period in the selected calendars (`--config-section`, plus any given with `--caldav-url`/`--calendar-url`) into the calendar of the config section given with `--to-section`, which must resolve to exactly one calendar (set `calendar_url` or `calendar_name` in it). The copies hold only the time, in UTC, and a fixed summary - no description, location, attendees, timezone or UID from the original. Run it regularly, e.g. from a systemd timer: copies are moved or deleted when the original changes, and other events in the target calendar are left alone. Copies are never copied onward, so syncing both ways is safe:

```
plann --config-section work freebusy-sync --to-section private --summary 'event from work calendar'
plann --config-section private freebusy-sync --to-section work --summary 'event from the personal calendar' --hours 9-16 --weekdays-only
```

`--hours` and `--weekdays-only` limit what is copied to working hours; an all-day event then becomes an event covering those hours. `--dry-run` shows what would change. If any calendar fails to load, the run stops without changing anything. The copies carry an `X-PLANN-FREEBUSY-SOURCE` property naming the sync (`--tag`, defaulting to the `--config-section` value); don't change the tag of a running sync, or the old copies are orphaned.

Switching from `select --freebusyhack`: delete the copies it made first. They carry neither the tag nor a recognisable UID, so `freebusy-sync` treats them as real events and copies them back the other way.

## See also

[NEXT_LEVEL.md](NEXT_LEVEL.md) describes some of my visions on what a good calendaring system should be capable of, and does an attempt on mapping this down to the icalendar standard.
71 changes: 68 additions & 3 deletions plann/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
## cli as such. It should be moved out and made available through
## `from plann import ...`

import datetime
import os
import sys

Expand Down Expand Up @@ -141,10 +142,14 @@ def cli(ctx, **kwargs):
## never touch the server - notably `plann <subcommand> --help` - don't
## connect to every configured calendar.
ctx.obj['calendars'] = _LazyCalendars(lambda: _discover_calendars(kwargs))
## the discovery options, for a command that must tighten them before the
## (lazy) discovery happens
ctx.obj['discovery_options'] = kwargs
## stashed for the `configure` subcommand (and any other command that needs
## to know which config file / section the user pointed at)
ctx.obj['config_file'] = kwargs['config_file']
ctx.obj['config_section'] = kwargs['config_section']
ctx.obj['raise_errors'] = kwargs['raise_errors']
for flag in ('show_native_timezone', 'store_timezone', 'implicit_timezone'):
setattr(tz, flag, kwargs[flag])

Expand All @@ -156,10 +161,23 @@ def _discover_calendars(kwargs):
config = read_config(kwargs['config_file'])
if config:
for meta_section in kwargs['config_section']:
for section in expand_config_section(config, meta_section):
calendars.extend(find_calendars(config_section(config, section), raise_errors=kwargs['raise_errors']))
calendars.extend(_config_calendars(config, meta_section, kwargs['raise_errors']))
return calendars

def _config_calendars(config, meta_section, raise_errors):
calendars = []
for section in expand_config_section(config, meta_section):
calendars.extend(find_calendars(config_section(config, section), raise_errors=raise_errors))
return calendars

def _section_calendars(ctx, meta_section):
"""Find the calendars of one config-file section, independent of the
sections selected with --config-section. Errors are raised."""
config = read_config(ctx.obj['config_file'])
if not config:
return []
return _config_calendars(config, meta_section, raise_errors=True)

@cli.command()
@click.pass_context
def list_calendars(ctx):
Expand Down Expand Up @@ -232,6 +250,53 @@ def _set_attr_options_(func, verb, desc=""):
def _set_attr_options(verb="", desc=""):
return lambda func: _set_attr_options_(func, verb, desc)

@cli.command()
@click.option('--to-section', required=True, help="Config section holding the target calendar (the first calendar found is used)")
@click.option('--summary', required=True, help="Summary of every copied event, e.g. 'event from work calendar'")
@click.option('--tag', help="Name of this sync, stored in the copies. Copies with the tag that no longer match a busy period are deleted. Defaults to the --config-section value(s)")
@click.option('--days', default=60, show_default=True, help="Sync from the start of today and this many days ahead")
@click.option('--hours', help="Only copy busy time within these local hours, e.g. 9-16. All-day events become events covering these hours")
@click.option('--weekdays-only/--all-days', default=False, help="Only copy busy time on Monday to Friday")
@click.option('--dry-run/--no-dry-run', default=False, help="Report what would change without changing anything")
@click.pass_context
def freebusy_sync(ctx, to_section, summary, tag, days, hours, weekdays_only, dry_run):
"""
Copy busy time from the selected calendars into another calendar.

Each busy period in the calendars selected by --config-section becomes
an event in the --to-section calendar holding only the time and
SUMMARY. Run it regularly: copies are moved and deleted as the
source changes. Example, run from a timer:

plann --config-section work freebusy-sync --to-section default --summary 'event from work calendar'
"""
from plann.freebusy import freebusy_sync as freebusy_sync_
if hours:
try:
hours = tuple(int(x) for x in hours.split('-'))
except ValueError:
hours = ()
if len(hours) != 2 or not 0 <= hours[0] < hours[1] <= 24:
raise click.BadParameter("expected two hours within 0-24, like 9-16", param_hint='--hours')
## A source that fails to load must stop the run: read as "no busy time",
## it would delete every copy.
ctx.obj['discovery_options']['raise_errors'] = True
try:
sources = list_type(ctx.obj['calendars'])
targets = _section_calendars(ctx, to_section)
except Exception as e:
_abort(f"Calendar discovery failed, nothing synced: {e}")
if not sources:
_abort("No source calendars found, nothing synced")
if len(targets) != 1:
_abort(f"Config section {to_section} must resolve to exactly one calendar, found {len(targets)}. Set calendar_url or calendar_name in it.")
start = _now().replace(hour=0, minute=0, second=0)
stats = freebusy_sync_(
sources, targets[0], start=start, end=start + datetime.timedelta(days=days),
summary=summary, tag=tag or ','.join(ctx.obj['config_section']), hours=hours,
weekdays=range(5) if weekdays_only else None, tzinfo=tz.implicit_timezone, dry_run=dry_run)
click.echo(("dry run, nothing changed: " if dry_run else "") + ", ".join(f"{k} {v}" for k, v in stats.items()))

@cli.group()
@click.option('--interactive/--no-interactive-select', help="line based interactive filtering")
@click.option('--mass-interactive/--no-mass-interactive-select', help="editor based interactive filtering")
Expand All @@ -258,7 +323,7 @@ def _set_attr_options(verb="", desc=""):
@click.option('--skip-children/--include-children', help="Skip children if it's parent is selected. Useful for getting an overview of the big picture if children are subtasks", default=False)
@click.option('--limit', help='Number of objects to show', type=int)
@click.option('--offset', help='Skip the first objects', type=int)
@click.option('--freebusyhack', help='removes almost everything from the ical and replaces the summary with the provided string. (this option is to be replaced with something better in a future release)')
@click.option('--freebusyhack', help='DEPRECATED, use the freebusy-sync command. Removes almost everything from the ical and replaces the summary with the provided string. Leaks the details of recurrence exceptions.')
@click.option('--pinned-tasks/--no-pinned-tasks', default=None, help='select all/no pinned tasks')
@click.pass_context
def select(*largs, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions plann/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ def __select(ctx, extend_objects=False, all=None, uid=[], abort_on_missing_uid=N
## (typically between private and work calendars) without carrying over
## (too much potentially) private/confidential information.
if freebusyhack:
logging.warning("--freebusyhack is deprecated, use the freebusy-sync command")
for obj in ctx.obj['objs']:
comp = obj.icalendar_component
attribs = list(comp.keys())
Expand Down
183 changes: 183 additions & 0 deletions plann/freebusy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Copy busy time from one set of calendars into another calendar.

Typical use is between a work calendar and a private calendar: the
target gets a placeholder event for every busy period in the source,
carrying nothing but the time and a fixed summary.

Every copy has a derived UID and an X-PLANN-FREEBUSY-SOURCE property
holding a tag naming the sync. The tag lets a later run find, move and
delete its own copies without touching anything else in the target
calendar, and lets copies be recognised so they never sync back.
"""

import datetime
import hashlib
from collections.abc import Iterable
from typing import Any

import icalendar
from dateutil import tz as dateutil_tz

SOURCE_PROP = 'X-PLANN-FREEBUSY-SOURCE'
UID_PREFIX = 'plann-freebusy-'


def _end_of(comp: icalendar.cal.Component) -> datetime.date | None:
start = comp['DTSTART'].dt
if 'DTEND' in comp:
return comp['DTEND'].dt
if 'DURATION' in comp:
return start + comp['DURATION'].dt
## RFC 5545 section 3.6.1: an all-day event lasts one day, a timed one no time
if not isinstance(start, datetime.datetime):
return start + datetime.timedelta(days=1)
return None


def _clip(start: datetime.date, end: datetime.date, hours: tuple[int, int], weekdays: Iterable[int],
tzinfo: datetime.tzinfo) -> list[tuple[datetime.date, datetime.datetime, datetime.datetime]]:
"""Cut a busy period down to the given hours (0-24), one piece per day.

All-day periods become the full hours on every allowed day.
"""
weekdays = set(weekdays)
if isinstance(start, datetime.datetime):
start = start if start.tzinfo else start.replace(tzinfo=tzinfo)
end = end if end.tzinfo else end.replace(tzinfo=tzinfo)
first_day = start.astimezone(tzinfo).date()
last_day = end.astimezone(tzinfo).date()
else:
first_day = start
last_day = end - datetime.timedelta(days=1)
pieces = []
day = first_day
while day <= last_day:
if day.weekday() in weekdays:
day_start = datetime.datetime.combine(day, datetime.time(hours[0]), tzinfo)
day_end = datetime.datetime.combine(day + datetime.timedelta(days=hours[1] // 24),
datetime.time(hours[1] % 24), tzinfo)
if isinstance(start, datetime.datetime):
piece_start, piece_end = max(start, day_start), min(end, day_end)
else:
piece_start, piece_end = day_start, day_end
if piece_start < piece_end:
pieces.append((day, piece_start, piece_end))
day += datetime.timedelta(days=1)
return pieces


def _to_utc(value: datetime.date, tzinfo: datetime.tzinfo) -> datetime.date:
"""Timestamps go out in UTC: no TZID to resolve, and no zone name leaks."""
if not isinstance(value, datetime.datetime):
return value
if not value.tzinfo:
value = value.replace(tzinfo=tzinfo)
return value.astimezone(datetime.timezone.utc)


def busy_copies(comp: icalendar.cal.Component, summary: str, tag: str, hours: tuple[int, int] | None = None,
weekdays: Iterable[int] | None = None, tzinfo: datetime.tzinfo | None = None,
window: tuple[datetime.datetime, datetime.datetime] | None = None) -> list[icalendar.Event]:
"""Return the placeholder events standing in for one (expanded) event.

The result is empty for events that do not block time (transparent
or cancelled), and for events that are themselves copies. With
``hours`` (a range within 0-24) or ``weekdays`` (0 is Monday) set,
the event is clipped to them in ``tzinfo``, which may split it into
one copy per day; ``window`` then drops the days outside it.
"""
if (str(comp.get('TRANSP', '')).upper() == 'TRANSPARENT'
or str(comp.get('STATUS', '')).upper() == 'CANCELLED'
or SOURCE_PROP in comp
or str(comp.get('UID', '')).startswith(UID_PREFIX)
or 'DTSTART' not in comp):
return []
start = comp['DTSTART'].dt
end = _end_of(comp)
if end is None:
return []
tzinfo = tzinfo or dateutil_tz.tzlocal()
recurrence_id = comp['RECURRENCE-ID'].to_ical().decode() if 'RECURRENCE-ID' in comp else ''
key = f"{tag}\0{comp['UID']}\0{recurrence_id}"
if hours or weekdays is not None:
pieces = _clip(start, end, hours or (0, 24), weekdays if weekdays is not None else range(7), tzinfo)
if window:
pieces = [p for p in pieces if p[1] < window[1] and p[2] > window[0]]
pieces = [(f"{key}\0{day.isoformat()}", s, e) for day, s, e in pieces]
else:
pieces = [(key, start, end)]
copies = []
for piece_key, piece_start, piece_end in pieces:
copy = icalendar.Event()
copy.add('UID', UID_PREFIX + hashlib.sha1(piece_key.encode()).hexdigest())
Comment thread
tobixen marked this conversation as resolved.
Dismissed
copy.add('DTSTAMP', datetime.datetime.now(datetime.timezone.utc))
copy.add('DTSTART', _to_utc(piece_start, tzinfo))
copy.add('DTEND', _to_utc(piece_end, tzinfo))
copy.add('SUMMARY', summary)
copy.add('TRANSP', 'OPAQUE')
copy.add(SOURCE_PROP, tag)
copies.append(copy)
return copies


def _vcal(comp: icalendar.cal.Component) -> str:
cal = icalendar.Calendar()
cal.add('PRODID', '-//plann//freebusy-sync//EN')
cal.add('VERSION', '2.0')
cal.add_component(comp)
return cal.to_ical().decode()


def _same(a: icalendar.cal.Component, b: icalendar.cal.Component) -> bool:
return all(a.get(p) is not None and b.get(p) is not None and a[p].dt == b[p].dt
for p in ('DTSTART', 'DTEND')) and str(a.get('SUMMARY')) == str(b.get('SUMMARY'))


def freebusy_sync(source_calendars: Iterable[Any], target: Any, start: datetime.datetime, end: datetime.datetime,
summary: str, tag: str, hours: tuple[int, int] | None = None, weekdays: Iterable[int] | None = None,
tzinfo: datetime.tzinfo | None = None, dry_run: bool = False) -> dict[str, int]:
"""Make the copies tagged ``tag`` in ``target`` mirror the busy time in
``source_calendars`` between ``start`` and ``end``.

Copies are added, moved or deleted as needed; other objects in the
target are never touched. Returns how many copies were added,
updated, deleted and left unchanged.

Raises ValueError when there are no source calendars, since an
unreachable source would otherwise read as no busy time at all and
delete every copy.
"""
source_calendars = list(source_calendars)
if not source_calendars:
raise ValueError("no source calendars; refusing to sync, as that would delete every copy")
wanted = {}
for calendar in source_calendars:
for obj in calendar.search(event=True, start=start, end=end, expand=True):
for copy in busy_copies(obj.icalendar_component, summary, tag, hours, weekdays, tzinfo, (start, end)):
wanted[str(copy['UID'])] = copy

existing = {}
for obj in target.search(event=True, start=start, end=end, expand=False):
comp = obj.icalendar_component
if str(comp.get(SOURCE_PROP, '')) == tag:
existing[str(comp['UID'])] = obj

stats = {'added': 0, 'updated': 0, 'deleted': 0, 'unchanged': 0}
for uid, copy in wanted.items():
obj = existing.pop(uid, None)
if obj is None:
stats['added'] += 1
if not dry_run:
target.add_event(_vcal(copy))
elif _same(obj.icalendar_component, copy):
stats['unchanged'] += 1
else:
stats['updated'] += 1
if not dry_run:
obj.data = _vcal(copy)
obj.save()
for obj in existing.values():
stats['deleted'] += 1
if not dry_run:
obj.delete()
return stats
Loading
Loading