Skip to content

Instantly share code, notes, and snippets.

@m0wer
Created January 8, 2026 08:14
Show Gist options
  • Select an option

  • Save m0wer/27599b1057840ba9418d9d2ce9d559c2 to your computer and use it in GitHub Desktop.

Select an option

Save m0wer/27599b1057840ba9418d9d2ce9d559c2 to your computer and use it in GitHub Desktop.
Create privacy blockers across multiple calendars
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Calendar Blockers - Create privacy blockers across multiple calendars
This script automatically creates "blocker" events on target calendars for every
event found in source calendars. The blocker events only show a generic title
(e.g., "private" or "busy") without revealing actual event details, helping
maintain privacy when sharing calendars.
Use Case:
- Share work calendar with personal calendar without revealing details
- Create busy blocks on shared calendars while keeping event info private
- Sync availability across multiple calendar systems
Features:
- Configurable blocker title
- Optional padding time before/after events
- Supports recurring events
- Dry-run mode for testing
- Works with vdirsyncer or similar calendar sync tools
Requirements:
pip install typer icalendar pytz recurring-ical-events
Usage:
# Normal run
python3 calblockers.py
# Dry run (test without creating events)
python3 calblockers.py --dry-run
# Verbose output
python3 calblockers.py --verbose
# Remove all blocker events (useful for cleanup/reset)
rm `rg -l "SUMMARY:private" ~/.calendars`
Setup:
1. Configure calendars below (source and target)
2. Calendars should be in ~/.calendars and synced by vdirsyncer
3. Run via cron for automatic updates
Note: This script only creates blocker events. It does not update or remove
existing blockers. To reset, delete all blocker events and run the script again.
Example cron:
27 */1 * * * ~/scripts/calblockers.py >> /tmp/calblockers.log 2>&1
26 14 * * * rg -l "SUMMARY:private" .calendars/ | xargs rm >> /tmp/calblockers.log 2>&1
"""
import typer
import os
import datetime
import icalendar
import pytz
import sys
import logging
import uuid
import recurring_ical_events
from functools import cache
# Configuration
# -------------
# The title shown on blocker events (e.g., "private", "busy", "blocked")
BLOCKER_TITLE: str = "private"
# Padding time in minutes to add before/after blocker events
# Useful if you need travel time or buffer between events
BLOCKER_PADDING_BEFORE: int = 0
BLOCKER_PADDING_AFTER: int = 0
# Source calendars to check for events
# Format: "calendar_provider/calendar_id" relative to ~/.calendars/
# Example: "google/primary" or "nextcloud/personal"
CALENDARS: list[str] = [
"provider1/calendar-uuid-1", # e.g., personal calendar
"provider1/calendar-uuid-2", # e.g., shared calendar
"provider2/calendar-uuid-3", # e.g., work calendar
]
# Target calendars where blocker events will be created
# Blockers won't be created on the same calendar as the source event
TARGET_CALENDARS: list[str] = [
"provider1/blockers-calendar-uuid",
"provider2/blockers-calendar-uuid",
]
# Script
# ------
logger = logging.getLogger(__name__)
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.WARNING)
app = typer.Typer()
@cache
def get_all_upcoming_events(
calendar: str, include_blockers: bool = True
) -> list[icalendar.Event]:
"""Get all upcoming events from a calendar.
Retrieves events up to 30 days in the future, including recurring events.
Args:
calendar: Calendar path relative to ~/.calendars/
include_blockers: Whether to include blocker events in results
Returns:
List of upcoming calendar events
"""
logger.debug(f"Getting upcoming events from {calendar}")
calendar_path = os.path.join(os.path.expanduser("~"), ".calendars", calendar)
now = datetime.datetime.now(pytz.utc)
events = []
# Read all .ics files in the calendar directory
for filename in os.listdir(calendar_path):
if filename.endswith(".ics"):
with open(os.path.join(calendar_path, filename), "rb") as file:
cal = icalendar.Calendar.from_ical(file.read())
for component in cal.walk():
if component.name == "VEVENT" and (
include_blockers or component.get("SUMMARY") != BLOCKER_TITLE
):
events.append(component)
# Filter to only upcoming events (next 30 days)
upcoming_events = []
for event in events:
# Handle both datetime and date-only events
event_start = event.get("DTSTART").dt
if isinstance(event_start, datetime.date) and not isinstance(event_start, datetime.datetime):
event_start_datetime = pytz.utc.localize(
datetime.datetime.combine(event_start, datetime.time.min)
)
else:
event_start_datetime = event_start
try:
# Include events in the next 30 days or recurring events
if (
event_start_datetime > now
and event_start_datetime < now + datetime.timedelta(days=30)
) or event.get("RRULE"):
if event.get("RRULE"):
# Expand recurring events
for recurrence_event in recurring_ical_events.of(event).between(
now, now + datetime.timedelta(days=30)
):
upcoming_events.append(recurrence_event)
else:
upcoming_events.append(event)
except TypeError as e:
logger.error(f"Skipping event {event.get('SUMMARY')}: {e}")
return upcoming_events
def get_blocker_event(event: icalendar.Event) -> icalendar.Event:
"""Create a blocker event from a source event.
The blocker event has the same time as the source event (plus padding)
but only shows the generic blocker title. The original event's UID is
stored in the description for tracking purposes.
Args:
event: Source event to create a blocker for
Returns:
Blocker event with generic title
"""
start = event.get("DTSTART").dt
# Handle events with duration vs. end time
if not event.get("DTEND"):
end = start + event.get("DURATION").dt
else:
end = event.get("DTEND").dt
# Create blocker with padding
blocker_event = icalendar.Event()
blocker_event.add("UID", uuid.uuid4().hex)
blocker_event.add("DTSTAMP", datetime.datetime.now(pytz.utc))
blocker_event.add(
"DTSTART", start - datetime.timedelta(minutes=BLOCKER_PADDING_BEFORE)
)
blocker_event.add("DTEND", end + datetime.timedelta(minutes=BLOCKER_PADDING_AFTER))
blocker_event.add("SUMMARY", BLOCKER_TITLE)
# Store original event UID for tracking/deduplication
blocker_event.add("DESCRIPTION", event.get("UID"))
return blocker_event
def main(dry_run: bool = False, verbose: bool = False):
"""Main function - creates blocker events across calendars.
Args:
dry_run: If True, show what would be created without actually creating events
verbose: Enable debug logging
"""
if verbose:
logger.setLevel(logging.DEBUG)
typer.echo("Starting calendar blocker script")
# Process each source calendar
for calendar in CALENDARS:
upcoming_events = get_all_upcoming_events(
calendar=calendar, include_blockers=False
)
# Create blocker for each event in target calendars
for event in upcoming_events:
blocker_event = get_blocker_event(event)
for target_calendar in TARGET_CALENDARS:
# Skip if target is same as source
if target_calendar == calendar:
continue
# Check if blocker already exists
blocker_event_exists = False
for target_event in get_all_upcoming_events(target_calendar):
if isinstance(target_event.get("DTSTART").dt, datetime.date) and not isinstance(target_event.get("DTSTART").dt, datetime.datetime):
raise RuntimeError(
f"Event {target_event.get('SUMMARY')} in {target_calendar} has no time component"
)
# Match by original UID and date
if (
target_event.get("DESCRIPTION") == blocker_event.get("DESCRIPTION")
and target_event.get("DTSTART").dt.date() == blocker_event.get("DTSTART").dt.date()
):
if (
target_event.get("DTSTART").dt > blocker_event.get("DTSTART").dt
and target_event.get("DTEND").dt > blocker_event.get("DTEND").dt
):
logger.warning(
f"Blocker for '{event.get('SUMMARY')}' exists in {target_calendar} "
f"but is shorter than current event"
)
blocker_event_exists = True
break
if blocker_event_exists:
logger.debug(
f"Blocker for '{event.get('SUMMARY')}' already exists in {target_calendar}"
)
continue
# Create the blocker event
if dry_run:
typer.secho(
f"[DRY RUN] Would create blocker in {target_calendar} for '{event.get('SUMMARY')}'",
fg=typer.colors.YELLOW
)
else:
typer.echo(
f"Creating blocker in {target_calendar} for '{event.get('SUMMARY')}'"
)
logger.debug(
f"Creating blocker with UID {blocker_event.get('UID')}"
)
calendar_path = os.path.join(
os.path.expanduser("~"), ".calendars", target_calendar
)
# Write blocker event to .ics file
with open(
os.path.join(calendar_path, f"{blocker_event.get('UID')}.ics"),
"wb",
) as file:
cal_object = icalendar.Calendar()
cal_object.add("PRODID", "-//CalBlockers//CalBlockers//EN")
cal_object.add("VERSION", "2.0")
cal_object.add("CALSCALE", "GREGORIAN")
cal_object.add_component(blocker_event)
file.write(cal_object.to_ical())
typer.secho("✓ Completed successfully", fg=typer.colors.GREEN)
if __name__ == "__main__":
typer.run(main)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment