Created
January 8, 2026 08:14
-
-
Save m0wer/27599b1057840ba9418d9d2ce9d559c2 to your computer and use it in GitHub Desktop.
Create privacy blockers across multiple calendars
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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