Last active
January 14, 2026 10:53
-
-
Save AlexanderMakarov/1ad84fc0eaaeaf62b17baf1f12347627 to your computer and use it in GitHub Desktop.
Google Calendar Notifier script for Linux to don't miss in-browser notifications
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 | |
| """ | |
| Google Calendar Notifier | |
| This script periodically checks Google Calendar for events with reminders and displays | |
| desktop notifications using notify-send. It is designed to run via systemd timer. | |
| HOW IT WORKS: | |
| - Systemd timer runs this script periodically (e.g., every minute) | |
| - Script queries Google Calendar API for events with reminders due within next 5 minutes | |
| - Sends desktop notifications via notify-send | |
| - Exits (systemd handles the next run) | |
| PREREQUISITES: | |
| 1. Install gcloud CLI and authenticate: | |
| gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly | |
| 2. Set quota project (if required by Calendar API): | |
| gcloud auth application-default set-quota-project YOUR_PROJECT_ID | |
| (Replace YOUR_PROJECT_ID with any GCP project ID you have access to) | |
| 3. Install Python requests package: | |
| pip install requests | |
| 4. Ensure notify-send is available (usually pre-installed on Linux desktop environments) | |
| SYSTEMD SETUP: | |
| 1. Create systemd service file: | |
| sudo nano /etc/systemd/system/gcal-notifier.service | |
| Add the following content (adjust paths as needed): | |
| [Unit] | |
| Description=Google Calendar Notifier | |
| After=network.target | |
| [Service] | |
| Type=oneshot | |
| User=YOUR_USERNAME | |
| Environment="DISPLAY=:0" | |
| Environment="XAUTHORITY=/home/YOUR_USERNAME/.Xauthority" | |
| ExecStart=/usr/bin/python3 /path/to/gcal_notifier.py | |
| StandardOutput=journal | |
| StandardError=journal | |
| KillMode=process | |
| [Install] | |
| WantedBy=multi-user.target | |
| Replace: | |
| - YOUR_USERNAME with your Linux username | |
| - /path/to/gcal_notifier.py with the actual path to this script | |
| 2. Create systemd timer file: | |
| sudo nano /etc/systemd/system/gcal-notifier.timer | |
| Add the following content: | |
| [Unit] | |
| Description=Run Google Calendar Notifier periodically | |
| Requires=gcal-notifier.service | |
| [Timer] | |
| OnBootSec=1min | |
| OnUnitActiveSec=1min | |
| AccuracySec=1s | |
| [Install] | |
| WantedBy=timers.target | |
| Timer interval options: | |
| - OnUnitActiveSec=1min (runs every 1 minute) | |
| - OnUnitActiveSec=5min (runs every 5 minutes) | |
| - OnCalendar=*:0/1 (runs every minute, cron-like syntax) | |
| - OnCalendar=*:0/5 (runs every 5 minutes) | |
| 3. Enable and start the timer: | |
| sudo systemctl daemon-reload | |
| sudo systemctl enable gcal-notifier.timer | |
| sudo systemctl start gcal-notifier.timer | |
| 4. Check status: | |
| systemctl status gcal-notifier.timer | |
| systemctl status gcal-notifier.service | |
| 5. View logs: | |
| journalctl -u gcal-notifier.service -f | |
| journalctl -u gcal-notifier.timer -f | |
| 6. Stop/restart: | |
| sudo systemctl stop gcal-notifier.timer | |
| sudo systemctl restart gcal-notifier.timer | |
| """ | |
| import subprocess | |
| import json | |
| import os | |
| import sys | |
| from datetime import datetime, timedelta | |
| from zoneinfo import ZoneInfo | |
| from urllib.parse import quote | |
| import requests | |
| # Configuration constants | |
| REMINDER_WINDOW_MINUTES = 5 | |
| NOTIFICATION_ICON = "x-office-calendar" | |
| NOTIFICATION_APP_NAME = "GoogleCalendar" | |
| NOTIFICATION_TIMEOUT_MS = 60000 | |
| def get_access_token(): | |
| """Get access token using gcloud CLI with Application Default Credentials.""" | |
| try: | |
| result = subprocess.run( | |
| ['gcloud', 'auth', 'application-default', 'print-access-token'], | |
| capture_output=True, | |
| text=True, | |
| check=True | |
| ) | |
| return result.stdout.strip() | |
| except subprocess.CalledProcessError as e: | |
| if 'not logged in' in e.stderr.lower() or 'no credentials' in e.stderr.lower(): | |
| raise Exception("Not authenticated. Run: gcloud auth application-default login") | |
| raise Exception(f"Failed to get access token: {e.stderr}") | |
| def get_quota_project(): | |
| """Get quota project from ADC credentials file.""" | |
| creds_path = os.path.expanduser('~/.config/gcloud/application_default_credentials.json') | |
| if os.path.exists(creds_path): | |
| try: | |
| with open(creds_path, 'r') as f: | |
| creds = json.load(f) | |
| if 'quota_project_id' in creds: | |
| return creds['quota_project_id'] | |
| except: | |
| pass | |
| return None | |
| def get_calendar_list(access_token, quota_project=None): | |
| """Get list of all calendars the user has access to.""" | |
| url = "https://www.googleapis.com/calendar/v3/users/me/calendarList" | |
| headers = {'Authorization': f'Bearer {access_token}'} | |
| if quota_project: | |
| headers['x-goog-user-project'] = quota_project | |
| try: | |
| response = requests.get(url, headers=headers) | |
| response.raise_for_status() | |
| return response.json().get('items', []) | |
| except requests.exceptions.HTTPError as e: | |
| if e.response.status_code == 403: | |
| error_data = e.response.json() if e.response.text else {} | |
| error_msg = error_data.get('error', {}).get('message', 'Unknown error') if error_data.get('error') else 'Unknown error' | |
| if 'quota project' in error_msg.lower(): | |
| print("Error: Calendar API requires a quota project (one-time setup).", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("1. Authenticate: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| print("2. Set quota project: gcloud auth application-default set-quota-project YOUR_PROJECT_ID", file=sys.stderr) | |
| print(" (Replace YOUR_PROJECT_ID with any GCP project ID you have access to)", file=sys.stderr) | |
| else: | |
| print("Error: 403 Forbidden", file=sys.stderr) | |
| print("This usually means:", file=sys.stderr) | |
| print("1. Your access token doesn't have Calendar API scopes, OR", file=sys.stderr) | |
| print("2. You're not authenticated", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| raise | |
| except requests.exceptions.RequestException as e: | |
| print(f"Failed to get calendar list: {e}", file=sys.stderr) | |
| raise | |
| def get_calendar_events(access_token, quota_project=None, calendar_id='primary', time_min=None, time_max=None): | |
| """Get events from a specific calendar.""" | |
| encoded_calendar_id = quote(calendar_id, safe='') | |
| url = f"https://www.googleapis.com/calendar/v3/calendars/{encoded_calendar_id}/events" | |
| params = { | |
| 'singleEvents': 'true', | |
| 'orderBy': 'startTime', | |
| } | |
| if time_min: | |
| params['timeMin'] = time_min | |
| if time_max: | |
| params['timeMax'] = time_max | |
| headers = {'Authorization': f'Bearer {access_token}'} | |
| if quota_project: | |
| headers['x-goog-user-project'] = quota_project | |
| try: | |
| response = requests.get(url, params=params, headers=headers) | |
| response.raise_for_status() | |
| return response.json().get('items', []) | |
| except requests.exceptions.HTTPError as e: | |
| if e.response.status_code == 403: | |
| error_data = e.response.json() if e.response.text else {} | |
| error_msg = error_data.get('error', {}).get('message', 'Unknown error') if error_data.get('error') else 'Unknown error' | |
| if 'quota project' in error_msg.lower(): | |
| print("Error: Calendar API requires a quota project (one-time setup).", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("1. Authenticate: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| print("2. Set quota project: gcloud auth application-default set-quota-project YOUR_PROJECT_ID", file=sys.stderr) | |
| print(" (Replace YOUR_PROJECT_ID with any GCP project ID you have access to)", file=sys.stderr) | |
| else: | |
| print("Error: 403 Forbidden", file=sys.stderr) | |
| print("This usually means:", file=sys.stderr) | |
| print("1. Your access token doesn't have Calendar API scopes, OR", file=sys.stderr) | |
| print("2. You're not authenticated", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| raise | |
| except requests.exceptions.RequestException as e: | |
| print(f"Failed to get calendar events: {e}", file=sys.stderr) | |
| raise | |
| def parse_event(event): | |
| """Parse event and extract basic info. | |
| Returns (title, location, start_time) tuple or None if invalid.""" | |
| title = event.get('summary', 'No title') | |
| location = event.get('location', '') | |
| start = event.get('start', {}) | |
| if 'dateTime' in start: | |
| start_time_str = start['dateTime'] | |
| elif 'date' in start: | |
| print(f" Event is all-day (date only), skipping") | |
| return None | |
| else: | |
| print(f" Event has no start time, skipping") | |
| return None | |
| try: | |
| start_time = datetime.fromisoformat(start_time_str.replace('Z', '+00:00')) | |
| if start_time.tzinfo is None: | |
| start_time = start_time.replace(tzinfo=ZoneInfo('UTC')) | |
| except Exception as e: | |
| print(f" Failed to parse start time: {e}") | |
| return None | |
| return (title, location, start_time) | |
| def send_notification(title, message): | |
| cmd = [ | |
| 'notify-send', | |
| '-a', NOTIFICATION_APP_NAME, | |
| '-i', NOTIFICATION_ICON, | |
| '-t', str(NOTIFICATION_TIMEOUT_MS), | |
| ] | |
| # Try to enable HTML formatting if available (some notify-send versions support it) | |
| # Check if message contains HTML tags | |
| if '<a href=' in message: | |
| # Some notification daemons support HTML, try with --hint | |
| cmd.extend(['--hint', 'string:body-markup:html']) | |
| cmd.extend([title, message]) | |
| try: | |
| subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) | |
| return True | |
| except (subprocess.CalledProcessError, FileNotFoundError): | |
| return False | |
| def main(): | |
| """Main execution flow.""" | |
| current_time = datetime.now(ZoneInfo('UTC')) | |
| # Query events that start within the next REMINDER_WINDOW_MINUTES | |
| time_min = current_time.isoformat() | |
| time_max = (current_time + timedelta(minutes=REMINDER_WINDOW_MINUTES)).isoformat() | |
| print(f"Starting check at {current_time.astimezone(datetime.now().astimezone().tzinfo).strftime('%Y-%m-%d %H:%M:%S')}") | |
| print(f"Querying events starting within next {REMINDER_WINDOW_MINUTES} minutes: {time_min} to {time_max}") | |
| notifications_sent = 0 | |
| try: | |
| print("Getting access token from gcloud...") | |
| access_token = get_access_token() | |
| print("Access token obtained") | |
| print("Getting quota project...") | |
| quota_project = get_quota_project() | |
| if quota_project: | |
| print(f"Quota project: {quota_project}") | |
| else: | |
| print("No quota project found") | |
| print("Getting calendar list...") | |
| calendars = get_calendar_list(access_token, quota_project) | |
| print(f"Found {len(calendars)} calendar(s):") | |
| for cal in calendars: | |
| print(f" - {cal.get('summary', 'Unknown')} (id: {cal.get('id', 'N/A')})") | |
| total_events = 0 | |
| for calendar in calendars: | |
| calendar_id = calendar.get('id') | |
| calendar_name = calendar.get('summary', calendar_id) | |
| print(f"\nQuerying calendar: {calendar_name} (id: {calendar_id})") | |
| try: | |
| events = get_calendar_events(access_token, quota_project, calendar_id, time_min, time_max) | |
| print(f" Found {len(events)} event(s)") | |
| total_events += len(events) | |
| for event in events: | |
| event_info = parse_event(event) | |
| if event_info is None: | |
| continue | |
| title, location, start_time = event_info | |
| time_diff = (start_time - current_time).total_seconds() / 60 | |
| print(f" Processing event: {title}") | |
| print(f" Event starts at: {start_time.astimezone(datetime.now().astimezone().tzinfo).strftime('%Y-%m-%d %H:%M:%S')} (in {time_diff:.1f} minutes)") | |
| # Only notify about events that start in the future (within next 5 minutes) | |
| if time_diff < 0: | |
| print(f" Event already started ({abs(time_diff):.1f} minutes ago), skipping") | |
| continue | |
| if time_diff > REMINDER_WINDOW_MINUTES: | |
| print(f" Event starts too far in the future ({time_diff:.1f} minutes), skipping") | |
| continue | |
| local_tz = datetime.now().astimezone().tzinfo | |
| start_time_str = start_time.astimezone(local_tz).strftime('%H:%M %Y-%m-%d') | |
| if location: | |
| # Check if location is a URL (starts with http) | |
| if location.startswith('http://') or location.startswith('https://'): | |
| # Format as HTML link if possible (notify-send supports basic HTML) | |
| message = f"Starting: {start_time_str}\nAt: <a href=\"{location}\">{location[:60]}</a>" | |
| else: | |
| message = f"Starting: {start_time_str}\nWhere: {location}" | |
| else: | |
| message = f"Starting: {start_time_str}" | |
| print(f" Sending notification for: {title}") | |
| if send_notification(title, message): | |
| print(f" ✓ Notification sent: {title}") | |
| notifications_sent += 1 | |
| else: | |
| print(f" ✗ Failed to send notification") | |
| except Exception as e: | |
| print(f"Error querying calendar {calendar_name}: {e}", file=sys.stderr) | |
| continue | |
| print(f"\nSummary: {total_events} events found, {notifications_sent} notification(s) sent") | |
| except Exception as e: | |
| print(f"Error: {e}", file=sys.stderr) | |
| import traceback | |
| traceback.print_exc() | |
| sys.exit(1) | |
| if notifications_sent == 0: | |
| print(f"No events starting within next {REMINDER_WINDOW_MINUTES} minutes") | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment