diff --git a/README.md b/README.md
index ae813a0..3825fa7 100644
--- a/README.md
+++ b/README.md
@@ -59,8 +59,8 @@ python3 -m gcalendar
## Help
```text
-usage: gcalendar [-h] [--list-calendars | --list-accounts | --status | --reset] [--calendar [CALENDAR [CALENDAR ...]]] [--no-of-days NO_OF_DAYS] [--account ACCOUNT]
- [--output {txt,json}] [--client-id CLIENT_ID] [--client-secret CLIENT_SECRET] [--version] [--debug]
+usage: gcalendar [-h] [--list-calendars | --list-accounts | --status | --reset | --setup-cron INTERVAL | --remove-cron] [--calendar [CALENDAR [CALENDAR ...]]] [--no-of-days NO_OF_DAYS] [--account ACCOUNT]
+ [--output {txt,json}] [--client-id CLIENT_ID] [--client-secret CLIENT_SECRET] [--notify NOTIFY] [--version] [--debug]
Read your Google Calendar events from terminal.
@@ -70,6 +70,8 @@ optional arguments:
--list-accounts list the id of gcalendar accounts
--status print the status of the gcalendar account
--reset reset the account
+ --setup-cron INTERVAL setup crontab to check every INTERVAL minutes
+ --remove-cron remove gcalendar crontab entry
--calendar [CALENDAR [CALENDAR ...]]
calendars to list events from
--no-of-days NO_OF_DAYS
@@ -80,6 +82,7 @@ optional arguments:
the Google client id
--client-secret CLIENT_SECRET
the Google client secret
+ --notify NOTIFY send notification before event (minutes)
--version show program's version number and exit
--debug run gcalendar in debug mode
```
@@ -137,6 +140,36 @@ gcalendar --account foo --reset
gcalendar --account bar --reset
```
+### Desktop Notifications
+
+```shell script
+# Get desktop notifications for events starting in the next 15 minutes
+gcalendar --notify 15
+
+# Get desktop notifications for events starting in the next 30 minutes
+gcalendar --notify 30
+```
+
+### Automated Notifications with Cron
+
+gcalendar can automatically check for upcoming events and send notifications:
+
+```shell script
+# Setup cron to check every 5 minutes and notify 15 minutes before events
+gcalendar --setup-cron 5 --notify 15
+
+# Setup cron to check every 10 minutes and notify 30 minutes before events
+gcalendar --setup-cron 10 --notify 30
+
+# Setup cron for a specific account and calendar
+gcalendar --setup-cron 5 --notify 15 --account work --calendar "Meeting Calendar" "Important Dates"
+
+# Remove gcalendar from crontab
+gcalendar --remove-cron
+```
+
+The cron job will run in the background and send desktop notifications when events are approaching.
+
## Issues
Run `gcalendar --debug` and create an [issue](https://github.com/slgobinath/gcalendar/issues) with the output.
diff --git a/gcalendar/__init__.py b/gcalendar/__init__.py
index cf6459c..cae159b 100644
--- a/gcalendar/__init__.py
+++ b/gcalendar/__init__.py
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-VERSION = "0.4.1"
+VERSION = "0.5.0"
TOKEN_STORAGE_VERSION = "v1"
diff --git a/gcalendar/__main__.py b/gcalendar/__main__.py
index bc6f323..fa4e66f 100755
--- a/gcalendar/__main__.py
+++ b/gcalendar/__main__.py
@@ -19,6 +19,9 @@
import argparse
import json
import os
+import sys
+import subprocess
+import logging
from datetime import datetime, timezone
from os.path import join
@@ -30,6 +33,7 @@
from gcalendar import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, TOKEN_STORAGE_VERSION, VERSION
from gcalendar.gcalendar import GCalendar
+from gcalendar.notification import notify_events
# the home folder
HOME_DIRECTORY = os.environ.get('HOME') or os.path.expanduser('~')
@@ -158,6 +162,104 @@ def handle_exception(client_id, client_secret, account_id, storage_path, output,
return failed, None
+def setup_crontab(interval, notify_minutes, account, calendars, debug):
+ """Setup or update a crontab entry for calendar notifications"""
+ # Get the current user's crontab
+ try:
+ result = subprocess.run(['crontab', '-l'], capture_output=True, text=True)
+ current_crontab = result.stdout
+ except subprocess.CalledProcessError:
+ # No existing crontab
+ current_crontab = ""
+
+ # Check if our cronjob is already there
+ gcal_job_prefix = "# GCalendar notification job"
+ if gcal_job_prefix in current_crontab:
+ print("Existing gcalendar crontab entry found. Updating...")
+ # Remove existing GCalendar crontab entries
+ new_lines = []
+ skip_line = False
+ for line in current_crontab.splitlines():
+ if line.startswith(gcal_job_prefix):
+ skip_line = True
+ continue
+ if skip_line and line.strip() and not line.startswith('#'):
+ skip_line = False
+ if not skip_line:
+ new_lines.append(line)
+
+ current_crontab = "\n".join(new_lines)
+
+ # Build the calendar argument string
+ calendar_arg = " ".join([f'"{cal}"' for cal in calendars]) if calendars != ["*"] else "*"
+
+ # Build the command with all options
+ notify_cmd = f"gcalendar-notify --notify {notify_minutes} --account {account}"
+ if calendar_arg != "*":
+ notify_cmd += f" --calendar {calendar_arg}"
+ if debug:
+ notify_cmd += " --debug"
+
+ # Add the new cronjob
+ new_crontab = current_crontab.rstrip() + f"\n\n{gcal_job_prefix}\n*/{interval} * * * * {notify_cmd}\n"
+
+ # Write to a temporary file
+ temp_crontab = os.path.join(HOME_DIRECTORY, ".gcalendar_crontab_temp")
+ with open(temp_crontab, "w") as f:
+ f.write(new_crontab)
+
+ # Install the new crontab
+ try:
+ subprocess.run(['crontab', temp_crontab], check=True)
+ print(f"Successfully set up crontab to check every {interval} minutes for upcoming events")
+ print(f"Will notify you {notify_minutes} minutes before each event")
+ except subprocess.CalledProcessError as e:
+ print(f"Failed to set up crontab: {e}")
+
+ # Clean up
+ os.remove(temp_crontab)
+
+
+def remove_crontab():
+ """Remove gcalendar crontab entries"""
+ try:
+ result = subprocess.run(['crontab', '-l'], capture_output=True, text=True)
+ current_crontab = result.stdout
+
+ if "# GCalendar notification job" not in current_crontab:
+ print("No gcalendar crontab entry found")
+ return
+
+ # Remove GCalendar crontab entries
+ new_lines = []
+ skip_line = False
+ for line in current_crontab.splitlines():
+ if line.startswith("# GCalendar notification job"):
+ skip_line = True
+ continue
+ if skip_line and line.strip() and not line.startswith('#'):
+ skip_line = False
+ if not skip_line:
+ new_lines.append(line)
+
+ new_crontab = "\n".join(new_lines)
+
+ # Write to a temporary file
+ temp_crontab = os.path.join(HOME_DIRECTORY, ".gcalendar_crontab_temp")
+ with open(temp_crontab, "w") as f:
+ f.write(new_crontab)
+
+ # Install the new crontab
+ subprocess.run(['crontab', temp_crontab], check=True)
+ print("Successfully removed gcalendar crontab entry")
+
+ # Clean up
+ os.remove(temp_crontab)
+
+ except subprocess.CalledProcessError as e:
+ print(f"Failed to remove crontab: {e}")
+
+
def process_request(account_ids, args):
client_id = args.client_id
client_secret = args.client_secret
@@ -204,6 +306,28 @@ def process_request(account_ids, args):
else:
calendars.extend(result)
print_list(calendars, args.output)
+ elif args.setup_cron is not None:
+ # Handle crontab setup
+ interval = args.setup_cron
+ notify_mins = int(args.notify) if args.notify else 15
+
+ if interval <= 0:
+ print("Error: Cron interval must be greater than 0")
+ return 1
+
+ for account_id in account_ids:
+ setup_crontab(
+ interval,
+ notify_mins,
+ account_id,
+ args.calendar,
+ args.debug
+ )
+ return 0
+ elif args.remove_cron:
+ # Remove the crontab entry
+ remove_crontab()
+ return 0
else:
# List events
no_of_days = int(args.no_of_days)
@@ -227,6 +351,12 @@ def process_request(account_ids, args):
else:
events.extend(result)
events = sorted(events, key=lambda event: event["start_date"] + event["start_time"])
+
+ # Handle notifications if requested
+ if args.notify:
+ notify_minutes = int(args.notify)
+ notify_events(events, notify_minutes)
+
print_events(events, args.output)
@@ -240,6 +370,9 @@ def main():
group.add_argument("--list-accounts", action="store_true", help="list the id of gcalendar accounts")
group.add_argument("--status", action="store_true", help="print the status of the gcalendar account")
group.add_argument("--reset", action="store_true", help="reset the account")
+ group.add_argument("--setup-cron", type=int, metavar="INTERVAL", help="setup crontab to check every INTERVAL minutes")
+ group.add_argument("--remove-cron", action="store_true", help="remove gcalendar crontab entry")
+
parser.add_argument("--calendar", type=str, default=["*"], nargs="*", help="calendars to list events from")
parser.add_argument("--since", type=validate_since, help="number of days to include")
parser.add_argument("--no-of-days", type=str, default="7", help="number of days to include")
@@ -249,10 +382,15 @@ def main():
parser.add_argument("--client-id", type=str, help="the Google client id")
parser.add_argument("--client-secret", type=str,
help="the Google client secret")
+ parser.add_argument("--notify", type=str, help="send notification before event (minutes)")
parser.add_argument('--version', action='version', version='%(prog)s ' + VERSION)
parser.add_argument("--debug", action="store_true", help="run gcalendar in debug mode")
args = parser.parse_args()
+ # Configure logging
+ log_level = logging.DEBUG if args.debug else logging.WARNING
+ logging.basicConfig(level=log_level, format='%(levelname)s: %(message)s')
+
# Create the config folder if not exists
if not os.path.exists(CONFIG_DIRECTORY):
os.mkdir(CONFIG_DIRECTORY)
@@ -261,4 +399,4 @@ def main():
if __name__ == "__main__":
- main()
+ sys.exit(main())
diff --git a/gcalendar/notification.py b/gcalendar/notification.py
new file mode 100644
index 0000000..590365c
--- /dev/null
+++ b/gcalendar/notification.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+# gcalendar is a tool to read Google Calendar events from your terminal.
+
+# Copyright (C) 2023 Gobinath
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+import subprocess
+import logging
+import os
+import json
+from datetime import datetime, timedelta
+from pathlib import Path
+
+# File to track already notified events to prevent duplicates
+NOTIFIED_EVENTS_FILE = os.path.join(
+ os.environ.get('XDG_DATA_HOME') or os.path.join(os.environ.get('HOME') or os.path.expanduser('~'), '.local/share'),
+ 'gcalendar/notified_events.json'
+)
+
+def notify_events(events, notify_minutes):
+ """
+ Check upcoming events and send notifications for those starting soon
+
+ Args:
+ events: List of calendar events
+ notify_minutes: Minutes before an event to send notification
+ """
+ now = datetime.now()
+ notification_window = now + timedelta(minutes=notify_minutes)
+
+ # Load previously notified events
+ notified_events = load_notified_events()
+
+ # Filter events that start within the notification window
+ upcoming_events = []
+ for event in events:
+ try:
+ # Handle events with specific times (not all-day events)
+ if event["start_time"] != "00:00" or event["end_time"] != "00:00":
+ start_datetime_str = f"{event['start_date']} {event['start_time']}"
+ start_datetime = datetime.strptime(start_datetime_str, "%Y-%m-%d %H:%M")
+
+ # Check if event starts between now and notification window
+ if now <= start_datetime <= notification_window:
+ # Create a unique ID for the event to prevent duplicate notifications
+ event_id = f"{event.get('summary', 'Unnamed')}-{start_datetime_str}"
+
+ # Only add if we haven't notified about this event already
+ if event_id not in notified_events:
+ upcoming_events.append(event)
+ notified_events[event_id] = datetime.now().isoformat()
+ except (ValueError, KeyError) as e:
+ logging.warning(f"Error processing event for notification: {e}")
+ continue
+
+ # Send notifications for upcoming events
+ for event in upcoming_events:
+ send_notification(event)
+
+ # Clean up old notified events (keep only events from the last 24 hours)
+ clean_notified_events(notified_events)
+
+ # Save updated notified events
+ save_notified_events(notified_events)
+
+def send_notification(event):
+ """
+ Send desktop notification for an event
+
+ Args:
+ event: Calendar event details
+ """
+ summary = event.get("summary", "Unnamed event")
+ location = event.get("location", "")
+ start_time = event.get("start_time", "")
+ start_date = event.get("start_date", "")
+ description = event.get("description", "")
+
+ # Create notification message
+ title = f"Upcoming Calendar Event: {summary}"
+ message_parts = []
+
+ message_parts.append(f"Date: {start_date}")
+ message_parts.append(f"Time: {start_time}")
+
+ if location:
+ message_parts.append(f"Location: {location}")
+
+ if description:
+ # Truncate description if it's too long
+ short_desc = description[:100] + "..." if len(description) > 100 else description
+ message_parts.append(f"Details: {short_desc}")
+
+ message = "\n".join(message_parts)
+
+ try:
+ # Use notify-send to display desktop notification
+ # Set a longer timeout (10 seconds = 10000 ms)
+ subprocess.call([
+ 'notify-send',
+ '--icon=appointment',
+ '--urgency=normal',
+ '--expire-time=10000',
+ title,
+ message
+ ])
+ logging.debug(f"Notification sent for event: {summary}")
+ return True
+ except Exception as e:
+ logging.error(f"Failed to send notification: {e}")
+ return False
+
+def load_notified_events():
+ """Load previously notified events from file"""
+ try:
+ if os.path.exists(NOTIFIED_EVENTS_FILE):
+ with open(NOTIFIED_EVENTS_FILE, 'r') as f:
+ return json.load(f)
+ except Exception as e:
+ logging.warning(f"Error loading notified events: {e}")
+ return {}
+
+def save_notified_events(notified_events):
+ """Save notified events to file"""
+ try:
+ # Ensure directory exists
+ os.makedirs(os.path.dirname(NOTIFIED_EVENTS_FILE), exist_ok=True)
+ with open(NOTIFIED_EVENTS_FILE, 'w') as f:
+ json.dump(notified_events, f)
+ except Exception as e:
+ logging.warning(f"Error saving notified events: {e}")
+
+def clean_notified_events(notified_events):
+ """Remove old events from the notified events dict"""
+ cutoff_time = datetime.now() - timedelta(hours=24)
+ to_remove = []
+
+ for event_id, timestamp_str in notified_events.items():
+ try:
+ timestamp = datetime.fromisoformat(timestamp_str)
+ if timestamp < cutoff_time:
+ to_remove.append(event_id)
+ except (ValueError, TypeError):
+ to_remove.append(event_id)
+
+ for event_id in to_remove:
+ notified_events.pop(event_id, None)
diff --git a/gcalendar/notify_cron.py b/gcalendar/notify_cron.py
new file mode 100644
index 0000000..bcbd089
--- /dev/null
+++ b/gcalendar/notify_cron.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# gcalendar is a tool to read Google Calendar events from your terminal.
+
+# Copyright (C) 2023 Gobinath
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+import os
+import sys
+import logging
+import argparse
+from datetime import datetime, timezone, timedelta
+from os.path import join
+
+from dateutil.relativedelta import relativedelta
+from oauth2client import client
+from oauth2client import clientsecrets
+
+# Ensure we can import from parent package
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+
+from gcalendar import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, TOKEN_STORAGE_VERSION
+from gcalendar.gcalendar import GCalendar
+from gcalendar.notification import notify_events
+
+# The home folder
+HOME_DIRECTORY = os.environ.get('HOME') or os.path.expanduser('~')
+
+# ~/.config/gcalendar folder
+CONFIG_DIRECTORY = os.path.join(os.environ.get(
+ 'XDG_CONFIG_HOME') or os.path.join(HOME_DIRECTORY, '.config'), 'gcalendar')
+
+# Ensure DISPLAY environment variable is set for notify-send
+if not os.environ.get('DISPLAY'):
+ os.environ['DISPLAY'] = ':0'
+
+# For notifications to work in cron
+if not os.environ.get('DBUS_SESSION_BUS_ADDRESS'):
+ try:
+ # Try to get the user's dbus session
+ user_id = os.getuid()
+ bus_file_path = f"/run/user/{user_id}/bus"
+ if os.path.exists(bus_file_path):
+ os.environ['DBUS_SESSION_BUS_ADDRESS'] = f"unix:path={bus_file_path}"
+ except Exception:
+ # Fallback to a common default
+ os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=/run/user/1000/bus'
+
+TOKEN_FILE_SUFFIX = "_" + TOKEN_STORAGE_VERSION + ".dat"
+
+def setup_logging(debug_mode):
+ """Set up logging configuration"""
+ log_dir = os.path.join(HOME_DIRECTORY, '.local/share/gcalendar')
+ os.makedirs(log_dir, exist_ok=True)
+ log_file = os.path.join(log_dir, 'notifier.log')
+
+ level = logging.DEBUG if debug_mode else logging.INFO
+
+ logging.basicConfig(
+ level=level,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.FileHandler(log_file),
+ logging.StreamHandler()
+ ]
+ )
+
+def get_events(account_id, client_id, client_secret, calendars, days, minutes):
+ """Get events from calendar"""
+ try:
+ storage_path = join(CONFIG_DIRECTORY, account_id + TOKEN_FILE_SUFFIX)
+ if not os.path.exists(storage_path):
+ logging.error(f"Account {account_id} is not authenticated. Run 'gcalendar' first.")
+ return []
+
+ # Setup time range - look from now until specified minutes in the future
+ current_time = datetime.now(timezone.utc).astimezone()
+ time_zone = current_time.tzinfo
+
+ # For checking events, we need to look ahead by days
+ start_time = str(current_time.isoformat())
+ end_time = str((current_time + relativedelta(days=days)).isoformat())
+
+ logging.debug(f"Checking for events between {start_time} and {end_time}")
+
+ g_calendar = GCalendar(client_id, client_secret, account_id, storage_path)
+ events = g_calendar.list_events(calendars, start_time, end_time, time_zone)
+ return events
+
+ except client.AccessTokenRefreshError:
+ logging.error(f"Failed to refresh access token for account {account_id}. Try running 'gcalendar --reset --account {account_id}'")
+ except clientsecrets.InvalidClientSecretsError:
+ logging.error("Invalid client secrets")
+ except Exception as e:
+ logging.error(f"Error retrieving events: {e}")
+
+ return []
+
+def main():
+ """Main function for the cron job notification script"""
+ parser = argparse.ArgumentParser(description="GCalendar notification cron job")
+ parser.add_argument("--account", type=str, default="default", help="account ID")
+ parser.add_argument("--notify", type=int, default=15, help="minutes before event to notify")
+ parser.add_argument("--days", type=int, default=1, help="days to look ahead for events")
+ parser.add_argument("--calendar", type=str, default=["*"], nargs="*", help="specific calendars to check")
+ parser.add_argument("--debug", action="store_true", help="enable debug logging")
+
+ args = parser.parse_args()
+
+ # Set up logging
+ setup_logging(args.debug)
+
+ # Log the beginning of the run with timestamp
+ logging.info(f"Starting notification check for account {args.account}")
+
+ # Get calendar events
+ events = get_events(
+ args.account,
+ DEFAULT_CLIENT_ID,
+ DEFAULT_CLIENT_SECRET,
+ [cal.lower() for cal in args.calendar],
+ args.days,
+ args.notify
+ )
+
+ if events:
+ logging.info(f"Found {len(events)} events in the next {args.days} days")
+ # Send notifications for events within the notification window
+ notify_events(events, args.notify)
+ else:
+ logging.info("No events found")
+
+ logging.info("Notification check complete")
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.py b/setup.py
index 50dd175..a97f7d4 100644
--- a/setup.py
+++ b/setup.py
@@ -28,19 +28,24 @@ def __package_files(directory):
setuptools.setup(
name="gcalendar",
- version="0.4.1",
+ version="0.5.0",
description="Read Google Calendar events from your terminal.",
long_description=long_description,
long_description_content_type="text/markdown",
author="Gobinath Loganathan",
author_email="slgobinath@gmail.com",
url="https://github.com/slgobinath/gcalendar",
- download_url="https://github.com/slgobinath/gcalendar/archive/v0.4.1.tar.gz",
+ download_url="https://github.com/slgobinath/gcalendar/archive/v0.5.0.tar.gz",
packages=setuptools.find_packages(),
package_data={},
install_requires=requires,
- entry_points={'console_scripts': ['gcalendar = gcalendar.__main__:main']},
- keywords='linux utility google-calendar',
+ entry_points={
+ 'console_scripts': [
+ 'gcalendar = gcalendar.__main__:main',
+ 'gcalendar-notify = gcalendar.notify_cron:main'
+ ]
+ },
+ keywords='linux utility google-calendar notifications',
classifiers=[
"Operating System :: POSIX :: Linux",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",