infra/.claude/calendar-query.py

400 lines
14 KiB
Python
Raw Normal View History

2026-02-06 20:10:02 +00:00
#!/usr/bin/env python3
"""
Nextcloud CalDAV Calendar Script
Queries and creates calendar events.
"""
import argparse
import json
import os
import sys
import uuid
from datetime import datetime, timedelta
from urllib.parse import urljoin, unquote
2026-02-06 20:10:02 +00:00
try:
import caldav
from icalendar import Calendar, Event, vText
except ImportError:
print("ERROR: Required packages not installed. Run:")
print(" pip install caldav icalendar")
sys.exit(1)
def cal_name(cal):
"""Get calendar display name, handling deprecation."""
try:
return unquote(cal.get_display_name() or str(cal.url).rstrip("/").split("/")[-1])
except Exception:
return unquote(str(cal.url).rstrip("/").split("/")[-1])
2026-02-06 20:10:02 +00:00
# Configuration from environment variables
NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "https://nextcloud.viktorbarzin.me")
CALDAV_URL = f"{NEXTCLOUD_URL}/remote.php/dav"
USERNAME = os.environ.get("NEXTCLOUD_USER")
APP_PASSWORD = os.environ.get("NEXTCLOUD_APP_PASSWORD")
if not USERNAME or not APP_PASSWORD:
print("ERROR: NEXTCLOUD_USER and NEXTCLOUD_APP_PASSWORD environment variables must be set.")
print("These should be set when activating the Claude venv (~/.venvs/claude)")
sys.exit(1)
def get_client():
"""Create CalDAV client connection."""
return caldav.DAVClient(
url=CALDAV_URL,
username=USERNAME,
password=APP_PASSWORD
)
def list_calendars():
"""List all available calendars."""
client = get_client()
principal = client.principal()
calendars = principal.calendars()
result = []
for cal in calendars:
result.append({
"name": cal_name(cal),
2026-02-06 20:10:02 +00:00
"url": str(cal.url)
})
return result
def get_events(calendar_name=None, start_date=None, end_date=None, days=7):
"""Get events from calendar(s) within a date range."""
client = get_client()
principal = client.principal()
calendars = principal.calendars()
if start_date is None:
start_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
if end_date is None:
end_date = start_date + timedelta(days=days)
all_events = []
for cal in calendars:
if calendar_name and cal_name(cal).lower() != calendar_name.lower():
2026-02-06 20:10:02 +00:00
continue
try:
events = cal.search(start=start_date, end=end_date, event=True, expand=True)
2026-02-06 20:10:02 +00:00
for event in events:
try:
ical = Calendar.from_ical(event.data)
for component in ical.walk():
if component.name == "VEVENT":
event_data = {
"calendar": cal_name(cal),
2026-02-06 20:10:02 +00:00
"summary": str(component.get("summary", "No title")),
"start": None,
"end": None,
"location": str(component.get("location", "")) or None,
"description": str(component.get("description", "")) or None,
"all_day": False
}
dtstart = component.get("dtstart")
dtend = component.get("dtend")
if dtstart:
dt = dtstart.dt
if hasattr(dt, 'hour'):
event_data["start"] = dt.strftime("%Y-%m-%d %H:%M")
else:
event_data["start"] = dt.strftime("%Y-%m-%d")
event_data["all_day"] = True
if dtend:
dt = dtend.dt
if hasattr(dt, 'hour'):
event_data["end"] = dt.strftime("%Y-%m-%d %H:%M")
else:
event_data["end"] = dt.strftime("%Y-%m-%d")
all_events.append(event_data)
except Exception as e:
pass # Skip malformed events
except Exception as e:
print(f"Warning: Could not fetch from {cal_name(cal)}: {e}", file=sys.stderr)
2026-02-06 20:10:02 +00:00
# Sort by start date
all_events.sort(key=lambda x: x["start"] or "")
return all_events
def create_event(summary, start_time, end_time=None, calendar_name="Personal",
location=None, description=None, all_day=False):
"""Create a new calendar event."""
client = get_client()
principal = client.principal()
calendars = principal.calendars()
# Find the target calendar
target_cal = None
for cal in calendars:
if cal_name(cal).lower() == calendar_name.lower():
2026-02-06 20:10:02 +00:00
target_cal = cal
break
if not target_cal:
# Try partial match
for cal in calendars:
if calendar_name.lower() in cal_name(cal).lower():
2026-02-06 20:10:02 +00:00
target_cal = cal
break
if not target_cal:
raise ValueError(f"Calendar '{calendar_name}' not found. Available: {[cal_name(c) for c in calendars]}")
2026-02-06 20:10:02 +00:00
# Create the event
cal = Calendar()
cal.add('prodid', '-//Claude Calendar Script//viktorbarzin.me//')
cal.add('version', '2.0')
event = Event()
event.add('summary', summary)
event.add('uid', str(uuid.uuid4()))
event.add('dtstamp', datetime.now())
if all_day:
event.add('dtstart', start_time.date())
if end_time:
event.add('dtend', end_time.date())
else:
event.add('dtend', (start_time + timedelta(days=1)).date())
else:
event.add('dtstart', start_time)
if end_time:
event.add('dtend', end_time)
else:
# Default to 1 hour duration
event.add('dtend', start_time + timedelta(hours=1))
if location:
event.add('location', location)
if description:
event.add('description', description)
cal.add_component(event)
# Save to calendar
target_cal.save_event(cal.to_ical().decode('utf-8'))
return {
"status": "created",
"summary": summary,
"calendar": cal_name(target_cal),
2026-02-06 20:10:02 +00:00
"start": start_time.strftime("%Y-%m-%d %H:%M") if not all_day else start_time.strftime("%Y-%m-%d"),
"end": end_time.strftime("%Y-%m-%d %H:%M") if end_time and not all_day else None
}
def format_events(events, output_format="text"):
"""Format events for display."""
if output_format == "json":
return json.dumps(events, indent=2)
if not events:
return "No events found."
lines = []
current_date = None
for event in events:
event_date = event["start"][:10] if event["start"] else "Unknown"
if event_date != current_date:
current_date = event_date
try:
dt = datetime.strptime(event_date, "%Y-%m-%d")
lines.append(f"\n## {dt.strftime('%A, %B %d, %Y')}")
except:
lines.append(f"\n## {event_date}")
time_str = ""
if not event["all_day"] and event["start"]:
time_str = event["start"][11:16]
if event["end"]:
time_str += f" - {event['end'][11:16]}"
else:
time_str = "All day"
line = f"- **{event['summary']}** ({time_str})"
if event["location"]:
line += f" @ {event['location']}"
if event["calendar"] != "personal":
line += f" [{event['calendar']}]"
lines.append(line)
if event["description"]:
# Truncate long descriptions
desc = event["description"][:200]
if len(event["description"]) > 200:
desc += "..."
lines.append(f" {desc}")
return "\n".join(lines)
def parse_date_arg(date_str):
"""Parse flexible date arguments."""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
if date_str == "today":
return today, today + timedelta(days=1)
elif date_str == "tomorrow":
return today + timedelta(days=1), today + timedelta(days=2)
elif date_str == "week" or date_str == "this week":
# Start from today, go to end of week (Sunday)
days_until_sunday = 6 - today.weekday()
return today, today + timedelta(days=days_until_sunday + 1)
elif date_str == "next week":
days_until_next_monday = 7 - today.weekday()
start = today + timedelta(days=days_until_next_monday)
return start, start + timedelta(days=7)
elif date_str == "month" or date_str == "this month":
return today, today + timedelta(days=30)
else:
# Try to parse as a date
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
return dt, dt + timedelta(days=1)
except:
return today, today + timedelta(days=7)
def parse_datetime(dt_str):
"""Parse flexible datetime strings."""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
# Handle relative dates with time
if dt_str.startswith("today "):
time_part = dt_str.replace("today ", "")
try:
t = datetime.strptime(time_part, "%H:%M")
return today.replace(hour=t.hour, minute=t.minute)
except:
pass
if dt_str.startswith("tomorrow "):
time_part = dt_str.replace("tomorrow ", "")
try:
t = datetime.strptime(time_part, "%H:%M")
return (today + timedelta(days=1)).replace(hour=t.hour, minute=t.minute)
except:
pass
# Try full datetime format
for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"]:
try:
return datetime.strptime(dt_str, fmt)
except:
continue
# Try date only
try:
return datetime.strptime(dt_str, "%Y-%m-%d")
except:
pass
raise ValueError(f"Could not parse datetime: {dt_str}. Use 'YYYY-MM-DD HH:MM' or 'tomorrow HH:MM'")
def main():
parser = argparse.ArgumentParser(description="Query and manage Nextcloud Calendar")
parser.add_argument("command", choices=["list", "events", "today", "tomorrow", "week", "month", "create"],
help="Command to run")
parser.add_argument("--calendar", "-c", default=None, help="Calendar name filter (default: all calendars)")
2026-02-06 20:10:02 +00:00
parser.add_argument("--days", "-d", type=int, default=7, help="Number of days to fetch")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--date", help="Specific date (YYYY-MM-DD) or relative (today, tomorrow, week, month)")
# Create event options
parser.add_argument("--title", "-t", help="Event title (for create)")
parser.add_argument("--start", "-s", help="Start time: 'YYYY-MM-DD HH:MM' or 'tomorrow 10:00'")
parser.add_argument("--end", "-e", help="End time: 'YYYY-MM-DD HH:MM' (optional, defaults to +1 hour)")
parser.add_argument("--location", "-l", help="Event location")
parser.add_argument("--description", help="Event description")
parser.add_argument("--all-day", action="store_true", help="Create all-day event")
args = parser.parse_args()
output_format = "json" if args.json else "text"
try:
if args.command == "list":
calendars = list_calendars()
if output_format == "json":
print(json.dumps(calendars, indent=2))
else:
print("Available calendars:")
for cal in calendars:
print(f" - {cal['name']}")
elif args.command == "events":
if args.date:
start, end = parse_date_arg(args.date)
else:
start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=args.days)
events = get_events(
calendar_name=args.calendar,
start_date=start,
end_date=end
)
print(format_events(events, output_format))
elif args.command in ["today", "tomorrow", "week", "month"]:
start, end = parse_date_arg(args.command)
events = get_events(
calendar_name=args.calendar,
start_date=start,
end_date=end
)
print(format_events(events, output_format))
elif args.command == "create":
if not args.title:
print("ERROR: --title is required for create command", file=sys.stderr)
sys.exit(1)
if not args.start:
print("ERROR: --start is required for create command", file=sys.stderr)
sys.exit(1)
# Parse start time
start_time = parse_datetime(args.start)
end_time = parse_datetime(args.end) if args.end else None
result = create_event(
summary=args.title,
start_time=start_time,
end_time=end_time,
calendar_name=args.calendar,
location=args.location,
description=args.description,
all_day=args.all_day
)
if output_format == "json":
print(json.dumps(result, indent=2))
else:
print(f"Event created: {result['summary']}")
print(f" Calendar: {result['calendar']}")
print(f" Start: {result['start']}")
if result['end']:
print(f" End: {result['end']}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()