316 lines
11 KiB
Python
316 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Simple CalDAV Calendar Tool for Google Calendar
|
|
Works with Gmail app passwords - no OAuth needed!
|
|
"""
|
|
import sys
|
|
import argparse
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
# This will be run with: uv run --with caldav cal.py
|
|
|
|
def get_credentials():
|
|
"""Get credentials from environment or .env file"""
|
|
import os
|
|
|
|
# Try to load from skills/imap-smtp-email/.env since we already have Gmail creds there
|
|
env_file = Path(__file__).parent.parent / 'imap-smtp-email' / '.env'
|
|
if env_file.exists():
|
|
for line in env_file.read_text().splitlines():
|
|
if line.strip() and not line.startswith('#') and '=' in line:
|
|
key, _, value = line.partition('=')
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if key not in os.environ:
|
|
os.environ[key] = value
|
|
|
|
email = os.environ.get('IMAP_USER') or os.environ.get('SMTP_USER')
|
|
password = os.environ.get('IMAP_PASS') or os.environ.get('SMTP_PASS')
|
|
|
|
if not email or not password:
|
|
print("Error: Email credentials not found. Set IMAP_USER and IMAP_PASS.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return email, password
|
|
|
|
def connect_caldav():
|
|
"""Connect to Calendar via CalDAV (Google, iCloud, or Work)"""
|
|
import caldav
|
|
import os
|
|
|
|
calendar_type = os.environ.get('CALENDAR_TYPE', 'google')
|
|
|
|
if calendar_type == 'icloud':
|
|
# iCloud CalDAV
|
|
email = os.environ.get('CALENDAR_ICLOUD_ID', 'anthonym_au@icloud.com')
|
|
password = os.environ.get('CALENDAR_ICLOUD_PASS', 'mvas-vwsk-ktiv-anex')
|
|
url = "https://caldav.icloud.com/"
|
|
|
|
print(f"Connecting to iCloud calendar for {email}...", file=sys.stderr)
|
|
client = caldav.DAVClient(url=url, username=email, password=password)
|
|
principal = client.principal()
|
|
return principal
|
|
|
|
elif calendar_type == 'work':
|
|
# Work calendar (Pacific Energy M365)
|
|
email = os.environ.get('CALENDAR_WORK_EMAIL', 'Anthony.martin@pacificenergy.com.au')
|
|
password = os.environ.get('CALENDAR_WORK_PASS', 'RecOvery2026!')
|
|
url = os.environ.get('CALENDAR_WORK_URL', 'https://outlook.office365.com/EWS/Exchange.asmx')
|
|
|
|
if not all([email, password, url]):
|
|
print("Error: Work calendar credentials not configured", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"Connecting to work calendar ({email})...", file=sys.stderr)
|
|
client = caldav.DAVClient(url=url, username=email, password=password)
|
|
principal = client.principal()
|
|
return principal
|
|
|
|
else:
|
|
# Google Calendar (default)
|
|
email, password = get_credentials()
|
|
url = f"https://calendar.google.com/calendar/dav/{email}/events/"
|
|
|
|
print(f"Connecting to Google calendar ({email})...", file=sys.stderr)
|
|
client = caldav.DAVClient(url=url, username=email, password=password)
|
|
principal = client.principal()
|
|
return principal
|
|
|
|
def cmd_list(args):
|
|
"""List all calendars"""
|
|
principal = connect_caldav()
|
|
calendars = principal.calendars()
|
|
|
|
if not calendars:
|
|
print("No calendars found")
|
|
return
|
|
|
|
print("Available calendars:")
|
|
for cal in calendars:
|
|
print(f" {cal.name}")
|
|
if args.verbose:
|
|
print(f" URL: {cal.url}")
|
|
print()
|
|
|
|
def cmd_agenda(args):
|
|
"""Show upcoming events"""
|
|
principal = connect_caldav()
|
|
calendars = principal.calendars()
|
|
|
|
# Time range
|
|
start = datetime.now()
|
|
if args.days:
|
|
end = start + timedelta(days=int(args.days))
|
|
else:
|
|
end = start + timedelta(days=7)
|
|
|
|
print(f"Events from {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}:\n")
|
|
|
|
for calendar in calendars:
|
|
if args.calendar and args.calendar.lower() not in calendar.name.lower():
|
|
continue
|
|
|
|
events = calendar.search(start=start, end=end, event=True, expand=True)
|
|
|
|
if not events:
|
|
continue
|
|
|
|
print(f"📅 {calendar.name}")
|
|
print("-" * 80)
|
|
|
|
for event in events:
|
|
try:
|
|
vevent = event.icalendar_component
|
|
summary = str(vevent.get('SUMMARY', 'No title'))
|
|
dtstart = vevent.get('DTSTART')
|
|
dtend = vevent.get('DTEND')
|
|
location = vevent.get('LOCATION', '')
|
|
description = vevent.get('DESCRIPTION', '')
|
|
|
|
# Format datetime
|
|
if hasattr(dtstart.dt, 'strftime'):
|
|
start_str = dtstart.dt.strftime('%Y-%m-%d %H:%M')
|
|
else:
|
|
start_str = str(dtstart.dt)
|
|
|
|
if hasattr(dtend.dt, 'strftime'):
|
|
end_str = dtend.dt.strftime('%H:%M')
|
|
else:
|
|
end_str = str(dtend.dt)
|
|
|
|
print(f"\n {summary}")
|
|
print(f" When: {start_str} - {end_str}")
|
|
|
|
if location:
|
|
print(f" Where: {location}")
|
|
|
|
if args.details and description:
|
|
print(f" Details: {description[:200]}{'...' if len(str(description)) > 200 else ''}")
|
|
|
|
except Exception as e:
|
|
print(f" [Error parsing event: {e}]")
|
|
|
|
print()
|
|
|
|
def cmd_today(args):
|
|
"""Show today's events"""
|
|
principal = connect_caldav()
|
|
calendars = principal.calendars()
|
|
|
|
start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
end = start + timedelta(days=1)
|
|
|
|
print(f"Today's events ({start.strftime('%Y-%m-%d')}):\n")
|
|
|
|
all_events = []
|
|
|
|
for calendar in calendars:
|
|
if args.calendar and args.calendar.lower() not in calendar.name.lower():
|
|
continue
|
|
|
|
events = calendar.search(start=start, end=end, event=True, expand=True)
|
|
|
|
for event in events:
|
|
try:
|
|
vevent = event.icalendar_component
|
|
summary = str(vevent.get('SUMMARY', 'No title'))
|
|
dtstart = vevent.get('DTSTART')
|
|
dtend = vevent.get('DTEND')
|
|
location = vevent.get('LOCATION', '')
|
|
|
|
all_events.append({
|
|
'summary': summary,
|
|
'start': dtstart.dt,
|
|
'end': dtend.dt,
|
|
'location': location,
|
|
'calendar': calendar.name
|
|
})
|
|
except:
|
|
pass
|
|
|
|
# Sort by start time
|
|
all_events.sort(key=lambda x: x['start'])
|
|
|
|
if not all_events:
|
|
print("No events today")
|
|
return
|
|
|
|
for evt in all_events:
|
|
if hasattr(evt['start'], 'strftime'):
|
|
start_str = evt['start'].strftime('%H:%M')
|
|
end_str = evt['end'].strftime('%H:%M')
|
|
print(f" {start_str}-{end_str} {evt['summary']}")
|
|
else:
|
|
print(f" All day {evt['summary']}")
|
|
|
|
if evt['location']:
|
|
print(f" 📍 {evt['location']}")
|
|
print(f" 📅 {evt['calendar']}")
|
|
print()
|
|
|
|
def cmd_create(args):
|
|
"""Create a new event"""
|
|
from icalendar import Calendar, Event as ICalEvent
|
|
from datetime import datetime
|
|
import pytz
|
|
|
|
principal = connect_caldav()
|
|
calendars = principal.calendars()
|
|
|
|
# Find calendar
|
|
target_cal = None
|
|
if args.calendar:
|
|
for cal in calendars:
|
|
if args.calendar.lower() in cal.name.lower():
|
|
target_cal = cal
|
|
break
|
|
else:
|
|
# Use first calendar
|
|
target_cal = calendars[0] if calendars else None
|
|
|
|
if not target_cal:
|
|
print(f"Error: Calendar '{args.calendar}' not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Parse datetime
|
|
try:
|
|
start_dt = datetime.fromisoformat(args.start)
|
|
end_dt = datetime.fromisoformat(args.end)
|
|
except:
|
|
print("Error: Invalid datetime format. Use YYYY-MM-DD HH:MM", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Create event
|
|
cal = Calendar()
|
|
event = ICalEvent()
|
|
event.add('summary', args.summary)
|
|
event.add('dtstart', start_dt)
|
|
event.add('dtend', end_dt)
|
|
|
|
if args.location:
|
|
event.add('location', args.location)
|
|
|
|
if args.description:
|
|
event.add('description', args.description)
|
|
|
|
cal.add_component(event)
|
|
|
|
# Save to calendar
|
|
target_cal.save_event(cal.to_ical())
|
|
print(f"✅ Event created: {args.summary}")
|
|
print(f" Calendar: {target_cal.name}")
|
|
print(f" When: {start_dt} - {end_dt}")
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Simple CalDAV Calendar Tool')
|
|
subparsers = parser.add_subparsers(dest='command', help='Command')
|
|
|
|
# list
|
|
list_parser = subparsers.add_parser('list', help='List all calendars')
|
|
list_parser.add_argument('-v', '--verbose', action='store_true', help='Show URLs')
|
|
|
|
# agenda
|
|
agenda_parser = subparsers.add_parser('agenda', help='Show upcoming events')
|
|
agenda_parser.add_argument('--days', default='7', help='Days ahead (default: 7)')
|
|
agenda_parser.add_argument('--calendar', help='Filter by calendar name')
|
|
agenda_parser.add_argument('--details', action='store_true', help='Show descriptions')
|
|
|
|
# today
|
|
today_parser = subparsers.add_parser('today', help='Show today\'s events')
|
|
today_parser.add_argument('--calendar', help='Filter by calendar name')
|
|
|
|
# create
|
|
create_parser = subparsers.add_parser('create', help='Create new event')
|
|
create_parser.add_argument('summary', help='Event title')
|
|
create_parser.add_argument('start', help='Start time (YYYY-MM-DD HH:MM)')
|
|
create_parser.add_argument('end', help='End time (YYYY-MM-DD HH:MM)')
|
|
create_parser.add_argument('--calendar', help='Calendar name')
|
|
create_parser.add_argument('--location', help='Location')
|
|
create_parser.add_argument('--description', help='Description')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.command:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
try:
|
|
if args.command == 'list':
|
|
cmd_list(args)
|
|
elif args.command == 'agenda':
|
|
cmd_agenda(args)
|
|
elif args.command == 'today':
|
|
cmd_today(args)
|
|
elif args.command == 'create':
|
|
cmd_create(args)
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
if '--verbose' in sys.argv or '-v' in sys.argv:
|
|
import traceback
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|