Skip to content

Instantly share code, notes, and snippets.

@AlexanderMakarov
Last active January 14, 2026 10:53
Show Gist options
  • Select an option

  • Save AlexanderMakarov/1ad84fc0eaaeaf62b17baf1f12347627 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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