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)",