caldav
Manage CalDAV calendars and events, with special support for Radicale server. Use when the user wants to create, update, delete, or query calendar events, manage calendars, or configure/administer a Radicale CalDAV server.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install openclaw-skills-caldav-skill
Repository
Skill path: skills/chakyiu/caldav-skill
Manage CalDAV calendars and events, with special support for Radicale server. Use when the user wants to create, update, delete, or query calendar events, manage calendars, or configure/administer a Radicale CalDAV server.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack, Backend.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: openclaw.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install caldav into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/openclaw/skills before adding caldav to shared team environments
- Use caldav for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: caldav
description: Manage CalDAV calendars and events, with special support for Radicale server. Use when the user wants to create, update, delete, or query calendar events, manage calendars, or configure/administer a Radicale CalDAV server.
homepage: https://github.com/python-caldav/caldav
metadata:
clawdbot:
emoji: "📅"
requires:
bins: ["python3"]
env: []
---
# CalDAV & Radicale Management
Interact with CalDAV servers (calendars, events, todos) and manage Radicale server configurations.
## Overview
CalDAV is a protocol for accessing and managing calendaring information (RFC 4791). Radicale is a lightweight CalDAV/CardDAV server. This skill enables:
- Calendar CRUD operations (create, list, update, delete)
- Event management (create, update, delete, query)
- Todo/task management
- Radicale server configuration and administration
## Prerequisites
### Python Library
Install the caldav library:
```bash
pip install caldav
```
For async support:
```bash
pip install caldav[async]
```
### Environment Variables (Recommended)
Store credentials securely in environment or config:
```bash
export CALDAV_URL="http://localhost:5232"
export CALDAV_USER="your_username"
export CALDAV_PASSWORD="your_password"
```
Or use a config file at `~/.config/caldav/config.json`:
```json
{
"url": "http://localhost:5232",
"username": "your_username",
"password": "your_password"
}
```
## Scripts
### Calendar Operations
```bash
# List all calendars
python3 {baseDir}/scripts/calendars.py list
# Create a new calendar
python3 {baseDir}/scripts/calendars.py create --name "Work Calendar" --id work
# Delete a calendar
python3 {baseDir}/scripts/calendars.py delete --id work
# Get calendar info
python3 {baseDir}/scripts/calendars.py info --id work
```
### Event Operations
```bash
# List events (all calendars, next 30 days)
python3 {baseDir}/scripts/events.py list
# List events from specific calendar
python3 {baseDir}/scripts/events.py list --calendar work
# List events in date range
python3 {baseDir}/scripts/events.py list --start 2024-01-01 --end 2024-01-31
# Create an event
python3 {baseDir}/scripts/events.py create \
--calendar work \
--summary "Team Meeting" \
--start "2024-01-15 14:00" \
--end "2024-01-15 15:00" \
--description "Weekly sync"
# Create all-day event
python3 {baseDir}/scripts/events.py create \
--calendar personal \
--summary "Birthday" \
--start 2024-02-14 \
--allday
# Update an event
python3 {baseDir}/scripts/events.py update \
--uid "event-uid-here" \
--summary "Updated Title"
# Delete an event
python3 {baseDir}/scripts/events.py delete --uid "event-uid-here"
# Search events by text
python3 {baseDir}/scripts/events.py search --query "meeting"
```
### Todo Operations
```bash
# List todos
python3 {baseDir}/scripts/todos.py list [--calendar name]
# Create a todo
python3 {baseDir}/scripts/todos.py create \
--calendar work \
--summary "Complete report" \
--due "2024-01-20"
# Complete a todo
python3 {baseDir}/scripts/todos.py complete --uid "todo-uid-here"
# Delete a todo
python3 {baseDir}/scripts/todos.py delete --uid "todo-uid-here"
```
### Radicale Server Management
```bash
# Check Radicale status
python3 {baseDir}/scripts/radicale.py status
# List users (from htpasswd file)
python3 {baseDir}/scripts/radicale.py users list
# Add user
python3 {baseDir}/scripts/radicale.py users add --username newuser
# View Radicale config
python3 {baseDir}/scripts/radicale.py config show
# Validate Radicale config
python3 {baseDir}/scripts/radicale.py config validate
# Check storage integrity
python3 {baseDir}/scripts/radicale.py storage verify
```
## Direct HTTP/DAV Operations
For low-level operations, use curl with CalDAV:
```bash
# Discover principal URL
curl -u user:pass -X PROPFIND \
-H "Depth: 0" \
-H "Content-Type: application/xml" \
-d '<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>' \
http://localhost:5232/
# List calendars
curl -u user:pass -X PROPFIND \
-H "Depth: 1" \
-H "Content-Type: application/xml" \
-d '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><c:calendar-timezone/><d:resourcetype/></d:prop></d:propfind>' \
http://localhost:5232/user/
# Query events by time range
curl -u user:pass -X REPORT \
-H "Depth: 1" \
-H "Content-Type: application/xml" \
-d '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="20240101T000000Z" end="20240131T235959Z"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>' \
http://localhost:5232/user/calendar/
# Create calendar (MKCALENDAR)
curl -u user:pass -X MKCALENDAR \
-H "Content-Type: application/xml" \
-d '<?xml version="1.0" encoding="utf-8"?>
<d:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set><d:prop>
<d:displayname>New Calendar</d:displayname>
</d:prop></d:set>
</d:mkcalendar>' \
http://localhost:5232/user/new-calendar/
```
## Radicale Configuration
### Config File Location
Radicale looks for config in:
- `/etc/radicale/config` (system-wide)
- `~/.config/radicale/config` (user)
- Custom path via `--config` or `RADICALE_CONFIG` env
### Key Configuration Sections
```ini
[server]
hosts = localhost:5232
max_connections = 20
max_content_length = 100000000
timeout = 30
ssl = False
[auth]
type = htpasswd
htpasswd_filename = /etc/radicale/users
htpasswd_encryption = autodetect
[storage]
filesystem_folder = /var/lib/radicale/collections
[rights]
type = owner_only
```
### Authentication Types
| Type | Description |
|------|-------------|
| `none` | No authentication (development only!) |
| `denyall` | Deny all (default since 3.5.0) |
| `htpasswd` | Apache htpasswd file |
| `remote_user` | WSGI-provided username |
| `http_x_remote_user` | Reverse proxy header |
| `ldap` | LDAP/AD authentication |
| `dovecot` | Dovecot auth socket |
| `imap` | IMAP server authentication |
| `oauth2` | OAuth2 authentication |
| `pam` | PAM authentication |
### Creating htpasswd Users
```bash
# Create new file with SHA-512
htpasswd -5 -c /etc/radicale/users user1
# Add another user
htpasswd -5 /etc/radicale/users user2
```
### Running as Service (systemd)
```bash
# Enable and start
sudo systemctl enable radicale
sudo systemctl start radicale
# Check status
sudo systemctl status radicale
# View logs
journalctl -u radicale -f
```
## iCalendar Format Quick Reference
Events use iCalendar (RFC 5545) format:
```ics
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//CalDAV Client//EN
BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20240115T120000Z
DTSTART:20240115T140000Z
DTEND:20240115T150000Z
SUMMARY:Team Meeting
DESCRIPTION:Weekly sync
LOCATION:Conference Room
END:VEVENT
END:VCALENDAR
```
### Common Properties
| Property | Description |
|----------|-------------|
| `UID` | Unique identifier |
| `DTSTART` | Start time |
| `DTEND` | End time |
| `DTSTAMP` | Creation/modification time |
| `SUMMARY` | Event title |
| `DESCRIPTION` | Event description |
| `LOCATION` | Location |
| `RRULE` | Recurrence rule |
| `EXDATE` | Excluded dates |
| `ATTENDEE` | Participant |
| `ORGANIZER` | Event organizer |
| `STATUS` | CONFIRMED/TENTATIVE/CANCELLED |
### Date Formats
```
# DateTime with timezone
DTSTART:20240115T140000Z ; UTC (Z suffix)
DTSTART;TZID=America/New_York:20240115T090000
# All-day event
DTSTART;VALUE=DATE:20240214
# DateTime local (floating)
DTSTART:20240115T140000
```
## Troubleshooting
### Connection Issues
```bash
# Test basic connectivity
curl -v http://localhost:5232/
# Check with authentication
curl -v -u user:pass http://localhost:5232/
# Verify CalDAV support
curl -X OPTIONS http://localhost:5232/ -I | grep -i dav
```
### Common Errors
| Error | Cause | Solution |
|-------|-------|----------|
| 401 Unauthorized | Wrong credentials | Check htpasswd file |
| 403 Forbidden | Rights restriction | Check `[rights]` config |
| 404 Not Found | Wrong URL path | Check principal/calendar path |
| 409 Conflict | Resource exists | Use different UID |
| 415 Unsupported Media Type | Wrong Content-Type | Use `text/calendar` |
### Debug Mode
Run Radicale with debug logging:
```bash
python3 -m radicale --debug
```
## Python API Quick Reference
```python
from caldav import DAVClient
# Connect
client = DAVClient(
url="http://localhost:5232",
username="user",
password="pass"
)
# Get principal
principal = client.principal()
# List calendars
for cal in principal.calendars():
print(f"Calendar: {cal.name} ({cal.url})")
# Create calendar
cal = principal.make_calendar(name="My Calendar", cal_id="my-cal")
# Create event
cal.save_event(
dtstart="2024-01-15 14:00",
dtend="2024-01-15 15:00",
summary="Meeting"
)
# Query events by date range
events = cal.date_search(
start="2024-01-01",
end="2024-01-31"
)
for event in events:
print(event.vobject_instance.vevent.summary.value)
# Get event by UID
event = cal.event_by_uid("event-uid")
# Delete event
event.delete()
# Create todo
todo = cal.save_todo(
summary="Task",
due="2024-01-20"
)
```
## Workflow Examples
### Create Recurring Event
```bash
python3 {baseDir}/scripts/events.py create \
--calendar work \
--summary "Weekly Standup" \
--start "2024-01-15 09:00" \
--end "2024-01-15 09:30" \
--rrule "FREQ=WEEKLY;BYDAY=MO,WE,FR"
```
### Export Calendar to ICS
```bash
# Via curl
curl -u user:pass http://localhost:5232/user/calendar/ > calendar.ics
# Via script
python3 {baseDir}/scripts/calendars.py export --id work --output work.ics
```
### Sync with Git (Radicale)
Configure Radicale to version changes:
```ini
[storage]
hook = git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"")
```
Initialize git in storage folder:
```bash
cd /var/lib/radicale/collections
git init
git config user.email "radicale@localhost"
```
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### _meta.json
```json
{
"owner": "chakyiu",
"slug": "caldav-skill",
"displayName": "Caldav",
"latest": {
"version": "1.0.0",
"publishedAt": 1771931461032,
"commit": "https://github.com/openclaw/skills/commit/acdb979268e3dfeee12dfd1b71f6a8a2f32b45d8"
},
"history": []
}
```
### scripts/calendars.py
```python
#!/usr/bin/env python3
"""Calendar operations for CalDAV."""
import sys
from pathlib import Path
# Add scripts dir to path
sys.path.insert(0, str(Path(__file__).parent))
from utils import (
get_client,
print_result,
load_config,
CalendarCommand,
)
def cmd_list(args):
"""List all calendars."""
client = get_client()
principal = client.principal()
calendars = principal.calendars()
if not calendars:
print_result(True, "No calendars found")
return
data = []
for cal in calendars:
info = {
"name": cal.name or "Unnamed",
"url": str(cal.url),
"id": str(cal.url).rstrip("/").split("/")[-1] if cal.url else None,
}
# Try to get additional properties
try:
props = cal.get_properties([("{DAV:}", "displayname"), ("{DAV:}", "resourcetype")])
info["displayname"] = props.get("{DAV:}displayname", "")
except Exception:
pass
data.append(info)
print_result(True, f"Found {len(calendars)} calendar(s)", {"calendars": data})
def cmd_create(args):
"""Create a new calendar."""
client = get_client()
principal = client.principal()
cal_id = args.id or args.name.lower().replace(" ", "-").replace("/", "-")
try:
cal = principal.make_calendar(
name=args.name,
cal_id=cal_id,
)
# Set additional properties if provided
if args.description or args.color:
props = {}
if args.description:
# Would need additional DAV property setting
pass
if args.color:
# Calendar color is often a custom property
pass
print_result(
True,
f"Calendar '{args.name}' created",
{"id": cal_id, "url": str(cal.url)},
)
except Exception as e:
if "already exists" in str(e).lower():
print_result(False, f"Calendar '{cal_id}' already exists")
else:
print_result(False, f"Failed to create calendar: {e}")
def cmd_delete(args):
"""Delete a calendar."""
client = get_client()
principal = client.principal()
# Find calendar by name or ID
calendar = None
for cal in principal.calendars():
cal_id = str(cal.url).rstrip("/").split("/")[-1]
if cal_id == args.id or cal.name == args.id:
calendar = cal
break
if not calendar:
print_result(False, f"Calendar '{args.id}' not found")
return
if not args.force:
# Ask for confirmation
try:
confirm = input(f"Delete calendar '{calendar.name}'? [y/N] ")
if confirm.lower() != "y":
print_result(False, "Cancelled")
return
except EOFError:
# Non-interactive mode
print_result(False, "Use --force to skip confirmation")
return
try:
calendar.delete()
print_result(True, f"Calendar '{args.id}' deleted")
except Exception as e:
print_result(False, f"Failed to delete calendar: {e}")
def cmd_info(args):
"""Get calendar info."""
client = get_client()
principal = client.principal()
# Find calendar
calendar = None
for cal in principal.calendars():
cal_id = str(cal.url).rstrip("/").split("/")[-1]
if cal_id == args.id or cal.name == args.id:
calendar = cal
break
if not calendar:
print_result(False, f"Calendar '{args.id}' not found")
return
info = {
"name": calendar.name,
"url": str(calendar.url),
"id": str(calendar.url).rstrip("/").split("/")[-1],
}
# Get properties
try:
from caldav.lib.namespace import ns
props = calendar.get_properties(
[
(ns("d"), "displayname"),
(ns("caldav"), "calendar-description"),
(ns("caldav"), "calendar-timezone"),
(ns("caldav"), "supported-calendar-component-set"),
]
)
info["displayname"] = props.get(ns("d", "displayname"), "")
info["description"] = props.get(ns("caldav", "calendar-description"), "")
info["timezone"] = props.get(ns("caldav", "calendar-timezone"), "")
components = props.get(ns("caldav", "supported-calendar-component-set"), "")
info["components"] = components if components else "VEVENT, VTODO"
except Exception:
pass
# Count events
try:
events = calendar.events()
todos = calendar.todos()
info["event_count"] = len(events)
info["todo_count"] = len(todos)
except Exception:
pass
print_result(True, f"Calendar info for '{args.id}'", info)
def cmd_export(args):
"""Export calendar to ICS."""
client = get_client()
principal = client.principal()
# Find calendar
calendar = None
for cal in principal.calendars():
cal_id = str(cal.url).rstrip("/").split("/")[-1]
if cal_id == args.id or cal.name == args.id:
calendar = cal
break
if not calendar:
print_result(False, f"Calendar '{args.id}' not found")
return
try:
# Get all events as ICS
events = calendar.events()
ics_content = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//OpenClaw CalDAV Export//EN\n"
for event in events:
# Get raw ICS data
data = event.data
# Extract VEVENT from the full VCALENDAR
if "BEGIN:VEVENT" in data:
start = data.find("BEGIN:VEVENT")
end = data.find("END:VEVENT") + len("END:VEVENT")
ics_content += data[start:end] + "\n"
ics_content += "END:VCALENDAR\n"
if args.output:
output_path = Path(args.output)
output_path.write_text(ics_content)
print_result(True, f"Exported to {args.output}", {"events": len(events)})
else:
print(ics_content)
except Exception as e:
print_result(False, f"Failed to export: {e}")
def cmd_import(args):
"""Import ICS file to calendar."""
client = get_client()
principal = client.principal()
# Find target calendar
calendar = None
for cal in principal.calendars():
cal_id = str(cal.url).rstrip("/").split("/")[-1]
if cal_id == args.calendar or cal.name == args.calendar:
calendar = cal
break
if not calendar:
print_result(False, f"Calendar '{args.calendar}' not found")
return
# Read ICS file
ics_path = Path(args.file)
if not ics_path.exists():
print_result(False, f"File not found: {args.file}")
return
ics_content = ics_path.read_text()
try:
# Parse and import events
from icalendar import Calendar
cal = Calendar.from_ical(ics_content)
imported = 0
for component in cal.walk():
if component.name == "VEVENT":
# Create event from component
ics_data = f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//OpenClaw Import//EN\nBEGIN:VEVENT\n"
# Add properties
for line in component.to_ical().decode().split("\n"):
if line and not line.startswith("BEGIN:VCALENDAR") and not line.startswith("END:VCALENDAR"):
ics_data += line + "\n"
ics_data += "END:VEVENT\nEND:VCALENDAR"
calendar.save_event(ics_data)
imported += 1
print_result(True, f"Imported {imported} event(s)", {"imported": imported})
except ImportError:
print_result(False, "icalendar library required for import. pip install icalendar")
except Exception as e:
print_result(False, f"Failed to import: {e}")
def main():
import argparse
parser = argparse.ArgumentParser(description="Calendar operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# List
subparsers.add_parser("list", help="List all calendars")
# Create
create = subparsers.add_parser("create", help="Create a new calendar")
create.add_argument("--name", required=True, help="Calendar display name")
create.add_argument("--id", help="Calendar ID (URL-safe)")
create.add_argument("--description", help="Calendar description")
create.add_argument("--color", help="Calendar color (hex)")
# Delete
delete = subparsers.add_parser("delete", help="Delete a calendar")
delete.add_argument("--id", required=True, help="Calendar ID or name")
delete.add_argument("--force", action="store_true", help="Skip confirmation")
# Info
info = subparsers.add_parser("info", help="Get calendar info")
info.add_argument("--id", required=True, help="Calendar ID or name")
# Export
export = subparsers.add_parser("export", help="Export calendar to ICS")
export.add_argument("--id", required=True, help="Calendar ID or name")
export.add_argument("--output", "-o", help="Output file path")
# Import
imp = subparsers.add_parser("import", help="Import ICS file")
imp.add_argument("--calendar", "-c", required=True, help="Target calendar")
imp.add_argument("--file", "-f", required=True, help="ICS file to import")
args = parser.parse_args()
# Dispatch
globals()[f"cmd_{args.command}"](args)
if __name__ == "__main__":
main()
```
### scripts/events.py
```python
#!/usr/bin/env python3
"""Event operations for CalDAV."""
import sys
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional
# Add scripts dir to path
sys.path.insert(0, str(Path(__file__).parent))
from utils import get_client, print_result, parse_datetime
def find_calendar(client, calendar_name: str):
"""Find calendar by name or ID."""
principal = client.principal()
for cal in principal.calendars():
cal_id = str(cal.url).rstrip("/").split("/")[-1]
if cal_id == calendar_name or cal.name == calendar_name:
return cal
return None
def format_event(event) -> dict:
"""Format event for display."""
data = {
"uid": event.icalendar_component.get("uid", "unknown"),
"url": str(event.url) if event.url else None,
}
vevent = event.icalendar_component
# Summary
if vevent.get("summary"):
data["summary"] = str(vevent["summary"])
# Description
if vevent.get("description"):
data["description"] = str(vevent["description"])
# Location
if vevent.get("location"):
data["location"] = str(vevent["location"])
# Dates
if vevent.get("dtstart"):
dt = vevent["dtstart"].dt
if hasattr(dt, "strftime"):
data["start"] = dt.isoformat()
else:
# Date object (all-day)
data["start"] = str(dt)
data["all_day"] = True
if vevent.get("dtend"):
dt = vevent["dtend"].dt
if hasattr(dt, "strftime"):
data["end"] = dt.isoformat()
else:
data["end"] = str(dt)
# Status
if vevent.get("status"):
data["status"] = str(vevent["status"])
# Recurrence
if vevent.get("rrule"):
data["recurrence"] = str(vevent["rrule"])
# Attendees
if vevent.get("attendee"):
attendees = vevent["attendee"]
if not isinstance(attendees, list):
attendees = [attendees]
data["attendees"] = [str(a) for a in attendees]
return data
def cmd_list(args):
"""List events."""
client = get_client()
principal = client.principal()
# Determine date range
if args.start:
start = parse_datetime(args.start)
else:
start = datetime.now() - timedelta(days=7)
if args.end:
end = parse_datetime(args.end)
else:
end = datetime.now() + timedelta(days=30)
all_events = []
if args.calendar:
calendars = [find_calendar(client, args.calendar)]
calendars = [c for c in calendars if c]
else:
calendars = principal.calendars()
for cal in calendars:
if not cal:
continue
try:
events = cal.date_search(start=start, end=end)
for event in events:
event_data = format_event(event)
event_data["calendar"] = cal.name
all_events.append(event_data)
except Exception as e:
# Some calendars might not support date_search
pass
# Sort by start date
all_events.sort(key=lambda e: e.get("start", ""))
# Limit results
all_events = all_events[: args.limit]
print_result(
True,
f"Found {len(all_events)} event(s)",
{"events": all_events, "range": {"start": str(start), "end": str(end)}},
)
def cmd_create(args):
"""Create a new event."""
client = get_client()
calendar = find_calendar(client, args.calendar)
if not calendar:
print_result(False, f"Calendar '{args.calendar}' not found")
return
# Parse dates
start = parse_datetime(args.start)
if args.allday:
# All-day event
start_date = start.date() if hasattr(start, "date") else start
end_date = None
if args.end:
end_dt = parse_datetime(args.end)
end_date = end_dt.date() if hasattr(end_dt, "date") else end_dt
else:
# Timed event
if args.end:
end = parse_datetime(args.end)
else:
# Default 1 hour duration
end = start + timedelta(hours=1)
try:
# Build event
if args.allday:
event = calendar.save_event(
dtstart=start_date,
dtend=end_date,
summary=args.summary,
description=args.description,
location=args.location,
)
else:
extra_kwargs = {}
if args.rrule:
extra_kwargs["rrule"] = args.rrule
if args.uid:
extra_kwargs["uid"] = args.uid
event = calendar.save_event(
dtstart=start,
dtend=end,
summary=args.summary,
description=args.description,
location=args.location,
**extra_kwargs,
)
event_data = format_event(event)
print_result(True, "Event created", event_data)
except Exception as e:
print_result(False, f"Failed to create event: {e}")
def cmd_update(args):
"""Update an existing event."""
client = get_client()
event = None
calendar = None
# Find event
if args.calendar:
calendar = find_calendar(client, args.calendar)
if calendar:
try:
event = calendar.event_by_uid(args.uid)
except Exception:
pass
if not event:
# Search all calendars
principal = client.principal()
for cal in principal.calendars():
try:
event = cal.event_by_uid(args.uid)
calendar = cal
break
except Exception:
pass
if not event:
print_result(False, f"Event '{args.uid}' not found")
return
try:
# Get existing component
vevent = event.icalendar_component
# Update properties
if args.summary:
vevent["summary"] = args.summary
if args.description is not None:
if args.description:
vevent["description"] = args.description
elif "description" in vevent:
del vevent["description"]
if args.location is not None:
if args.location:
vevent["location"] = args.location
elif "location" in vevent:
del vevent["location"]
if args.status:
vevent["status"] = args.status
if args.start:
start = parse_datetime(args.start)
vevent["dtstart"] = vevent.pop("dtstart")
vevent["dtstart"].dt = start
if args.end:
end = parse_datetime(args.end)
vevent["dtend"] = vevent.pop("dtend")
vevent["dtend"].dt = end
# Save changes
event.save()
event_data = format_event(event)
print_result(True, "Event updated", event_data)
except Exception as e:
print_result(False, f"Failed to update event: {e}")
def cmd_delete(args):
"""Delete an event."""
client = get_client()
event = None
# Find event
if args.calendar:
calendar = find_calendar(client, args.calendar)
if calendar:
try:
event = calendar.event_by_uid(args.uid)
except Exception:
pass
if not event:
principal = client.principal()
for cal in principal.calendars():
try:
event = cal.event_by_uid(args.uid)
break
except Exception:
pass
if not event:
print_result(False, f"Event '{args.uid}' not found")
return
if not args.force:
try:
confirm = input(f"Delete event '{args.uid}'? [y/N] ")
if confirm.lower() != "y":
print_result(False, "Cancelled")
return
except EOFError:
print_result(False, "Use --force to skip confirmation")
return
try:
event.delete()
print_result(True, f"Event '{args.uid}' deleted")
except Exception as e:
print_result(False, f"Failed to delete event: {e}")
def cmd_search(args):
"""Search events by text."""
client = get_client()
principal = client.principal()
results = []
query_lower = args.query.lower()
calendars = principal.calendars()
if args.calendar:
cal = find_calendar(client, args.calendar)
if cal:
calendars = [cal]
else:
calendars = []
for cal in calendars:
try:
events = cal.events()
for event in events:
vevent = event.icalendar_component
# Search in summary, description, location
searchable = []
if vevent.get("summary"):
searchable.append(str(vevent["summary"]))
if vevent.get("description"):
searchable.append(str(vevent["description"]))
if vevent.get("location"):
searchable.append(str(vevent["location"]))
text = " ".join(searchable).lower()
if query_lower in text:
event_data = format_event(event)
event_data["calendar"] = cal.name
results.append(event_data)
except Exception:
pass
print_result(True, f"Found {len(results)} matching event(s)", {"events": results})
def cmd_get(args):
"""Get event by UID."""
client = get_client()
event = None
calendar = None
if args.calendar:
calendar = find_calendar(client, args.calendar)
if calendar:
try:
event = calendar.event_by_uid(args.uid)
except Exception:
pass
if not event:
principal = client.principal()
for cal in principal.calendars():
try:
event = cal.event_by_uid(args.uid)
calendar = cal
break
except Exception:
pass
if not event:
print_result(False, f"Event '{args.uid}' not found")
return
event_data = format_event(event)
event_data["calendar"] = calendar.name if calendar else None
event_data["raw"] = event.data
print_result(True, "Event found", event_data)
def main():
import argparse
parser = argparse.ArgumentParser(description="Event operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# List
list_cmd = subparsers.add_parser("list", help="List events")
list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
list_cmd.add_argument("--start", help="Start date (YYYY-MM-DD)")
list_cmd.add_argument("--end", help="End date (YYYY-MM-DD)")
list_cmd.add_argument("--limit", type=int, default=50, help="Max results")
# Create
create = subparsers.add_parser("create", help="Create an event")
create.add_argument("--calendar", "-c", required=True, help="Target calendar")
create.add_argument("--summary", "-s", required=True, help="Event title")
create.add_argument("--start", required=True, help="Start datetime")
create.add_argument("--end", help="End datetime")
create.add_argument("--allday", action="store_true", help="All-day event")
create.add_argument("--description", "-d", help="Description")
create.add_argument("--location", "-l", help="Location")
create.add_argument("--rrule", help="Recurrence rule")
create.add_argument("--uid", help="Custom UID")
# Update
update = subparsers.add_parser("update", help="Update an event")
update.add_argument("--uid", required=True, help="Event UID")
update.add_argument("--calendar", "-c", help="Calendar")
update.add_argument("--summary", "-s", help="New title")
update.add_argument("--start", help="New start")
update.add_argument("--end", help="New end")
update.add_argument("--description", "-d", help="New description")
update.add_argument("--location", "-l", help="New location")
update.add_argument("--status", choices=["CONFIRMED", "TENTATIVE", "CANCELLED"])
# Delete
delete = subparsers.add_parser("delete", help="Delete an event")
delete.add_argument("--uid", required=True, help="Event UID")
delete.add_argument("--calendar", "-c", help="Calendar")
delete.add_argument("--force", action="store_true", help="Skip confirmation")
# Search
search = subparsers.add_parser("search", help="Search events")
search.add_argument("--query", "-q", required=True, help="Search query")
search.add_argument("--calendar", "-c", help="Filter by calendar")
# Get
get = subparsers.add_parser("get", help="Get event by UID")
get.add_argument("--uid", required=True, help="Event UID")
get.add_argument("--calendar", "-c", help="Calendar")
args = parser.parse_args()
globals()[f"cmd_{args.command}"](args)
if __name__ == "__main__":
main()
```
### scripts/radicale.py
```python
#!/usr/bin/env python3
"""Radicale server management operations."""
import os
import sys
import json
import subprocess
from pathlib import Path
from typing import Dict, Optional, List
# Default paths
DEFAULT_CONFIG_PATHS = [
Path("/etc/radicale/config"),
Path.home() / ".config" / "radicale" / "config",
]
DEFAULT_STORAGE_PATH = "/var/lib/radicale/collections"
DEFAULT_USERS_PATH = "/etc/radicale/users"
def print_result(success: bool, message: str, data: Optional[Dict] = None):
"""Print result in consistent format."""
result = {"success": success, "message": message}
if data:
result["data"] = data
print(json.dumps(result, indent=2, default=str))
def find_config() -> Optional[Path]:
"""Find Radicale config file."""
# Check environment variable
if os.environ.get("RADICALE_CONFIG"):
path = Path(os.environ["RADICALE_CONFIG"])
if path.exists():
return path
# Check default paths
for path in DEFAULT_CONFIG_PATHS:
if path.exists():
return path
return None
def parse_config(config_path: Path) -> Dict:
"""Parse Radicale INI config file."""
import configparser
config = configparser.ConfigParser()
config.read(config_path)
result = {}
for section in config.sections():
result[section] = dict(config[section])
return result
def cmd_status(args):
"""Check Radicale server status."""
data = {}
# Check if config exists
config_path = find_config()
if config_path:
data["config_path"] = str(config_path)
else:
data["config_path"] = None
data["config_warning"] = "No config file found"
# Check if service is running (systemd)
try:
result = subprocess.run(
["systemctl", "status", "radicale"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
data["service_status"] = "running"
# Extract active state
for line in result.stdout.split("\n"):
if "Active:" in line:
data["service_active"] = line.split("Active:")[1].strip()
break
else:
data["service_status"] = "stopped or not installed"
except FileNotFoundError:
data["service_status"] = "systemd not available"
except subprocess.TimeoutExpired:
data["service_status"] = "timeout checking service"
except Exception as e:
data["service_status"] = f"error: {e}"
# Check if process is running (non-systemd)
if data.get("service_status") != "running":
try:
result = subprocess.run(
["pgrep", "-f", "radicale"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
data["process_running"] = True
data["pids"] = result.stdout.strip().split("\n")
except Exception:
pass
# Check storage
config = parse_config(config_path) if config_path else {}
storage_path = config.get("storage", {}).get("filesystem_folder", DEFAULT_STORAGE_PATH)
storage = Path(storage_path)
if storage.exists():
data["storage_path"] = str(storage)
# Count collections
try:
collections = list(storage.glob("*/*"))
data["collection_count"] = len([c for c in collections if c.is_dir()])
except Exception:
pass
else:
data["storage_path"] = str(storage)
data["storage_exists"] = False
# Check users file
auth_type = config.get("auth", {}).get("type", "none")
data["auth_type"] = auth_type
if auth_type == "htpasswd":
users_path = config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH)
if Path(users_path).exists():
data["users_file"] = users_path
try:
with open(users_path) as f:
data["user_count"] = len([l for l in f if l.strip() and not l.startswith("#")])
except Exception:
pass
else:
data["users_file"] = users_path
data["users_file_exists"] = False
print_result(True, "Radicale status", data)
def cmd_config_show(args):
"""Show Radicale configuration."""
config_path = find_config()
if not config_path:
print_result(False, "No Radicale config file found")
return
config = parse_config(config_path)
print_result(True, f"Config from {config_path}", {"config": config, "path": str(config_path)})
def cmd_config_validate(args):
"""Validate Radicale configuration."""
config_path = find_config()
if not config_path:
print_result(False, "No Radicale config file found")
return
issues = []
warnings = []
config = parse_config(config_path)
# Check auth
auth_type = config.get("auth", {}).get("type", "denyall")
if auth_type == "none":
issues.append("Authentication is disabled (auth.type=none) - anyone can access!")
elif auth_type == "denyall":
warnings.append("Authentication is denyall - no users can log in")
# Check if htpasswd file exists for htpasswd auth
if auth_type == "htpasswd":
users_file = config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH)
if not Path(users_file).exists():
issues.append(f"htpasswd file not found: {users_file}")
# Check storage
storage_path = config.get("storage", {}).get("filesystem_folder", DEFAULT_STORAGE_PATH)
storage = Path(storage_path)
if not storage.exists():
warnings.append(f"Storage path does not exist: {storage_path}")
else:
# Check permissions
if not os.access(storage, os.W_OK):
issues.append(f"No write permission on storage: {storage_path}")
# Check SSL
server_config = config.get("server", {})
hosts = server_config.get("hosts", "localhost:5232")
if server_config.get("ssl") == "True":
cert = server_config.get("certificate", "/etc/ssl/radicale.cert.pem")
key = server_config.get("key", "/etc/ssl/radicale.key.pem")
if not Path(cert).exists():
issues.append(f"SSL certificate not found: {cert}")
if not Path(key).exists():
issues.append(f"SSL key not found: {key}")
else:
# Check if bound to public interface without SSL
if "0.0.0.0" in hosts or "::" in hosts:
warnings.append("Server bound to all interfaces without SSL - traffic is unencrypted!")
result = {
"config_path": str(config_path),
"issues": issues,
"warnings": warnings,
"valid": len(issues) == 0,
}
if issues or warnings:
print_result(result["valid"], "Configuration has issues", result)
else:
print_result(True, "Configuration is valid", result)
def cmd_users_list(args):
"""List Radicale users from htpasswd file."""
config_path = find_config()
if not config_path:
# Try to find users file directly
users_path = Path(args.users_file or DEFAULT_USERS_PATH)
else:
config = parse_config(config_path)
auth_type = config.get("auth", {}).get("type", "none")
if auth_type != "htpasswd":
print_result(False, f"Auth type is '{auth_type}', not htpasswd")
return
users_path = Path(config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH))
if not users_path.exists():
print_result(False, f"Users file not found: {users_path}")
return
users = []
with open(users_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
parts = line.split(":", 1)
if len(parts) == 2:
users.append(
{
"username": parts[0],
"has_password": True,
}
)
print_result(True, f"Found {len(users)} user(s)", {"users": users, "file": str(users_path)})
def cmd_users_add(args):
"""Add a user to htpasswd file."""
config_path = find_config()
if config_path:
config = parse_config(config_path)
users_path = Path(config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH))
else:
users_path = Path(args.users_file or DEFAULT_USERS_PATH)
# Check if htpasswd command exists
try:
subprocess.run(["htpasswd", "--help"], capture_output=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
print_result(False, "htpasswd command not found. Install apache2-utils or httpd-tools")
return
# Build htpasswd command
cmd = ["htpasswd"]
if args.encryption:
if args.encryption == "sha512":
cmd.append("-5")
elif args.encryption == "sha256":
cmd.append("-s")
elif args.encryption == "bcrypt":
cmd.append("-B")
elif args.encryption == "md5":
cmd.append("-m")
elif args.encryption == "plain":
# Will add manually
pass
# Create file if doesn't exist
if not users_path.exists():
cmd.append("-c")
cmd.extend([str(users_path), args.username])
try:
# Run htpasswd interactively (will prompt for password)
result = subprocess.run(cmd)
if result.returncode == 0:
print_result(True, f"User '{args.username}' added to {users_path}")
else:
print_result(False, f"Failed to add user: htpasswd returned {result.returncode}")
except Exception as e:
print_result(False, f"Failed to add user: {e}")
def cmd_users_remove(args):
"""Remove a user from htpasswd file."""
config_path = find_config()
if config_path:
config = parse_config(config_path)
users_path = Path(config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH))
else:
users_path = Path(args.users_file or DEFAULT_USERS_PATH)
if not users_path.exists():
print_result(False, f"Users file not found: {users_path}")
return
# Read existing users
lines = []
found = False
with open(users_path) as f:
for line in f:
if line.strip().startswith(args.username + ":"):
found = True
else:
lines.append(line)
if not found:
print_result(False, f"User '{args.username}' not found")
return
# Write back
with open(users_path, "w") as f:
f.writelines(lines)
print_result(True, f"User '{args.username}' removed")
def cmd_storage_verify(args):
"""Verify Radicale storage integrity."""
config_path = find_config()
if config_path:
config = parse_config(config_path)
storage_path = Path(config.get("storage", {}).get("filesystem_folder", DEFAULT_STORAGE_PATH))
else:
storage_path = Path(args.storage_path or DEFAULT_STORAGE_PATH)
if not storage_path.exists():
print_result(False, f"Storage path not found: {storage_path}")
return
# Run radicale --verify-storage if available
try:
result = subprocess.run(
["python3", "-m", "radicale", "--verify-storage"],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0:
print_result(True, "Storage verified", {"output": result.stdout})
else:
print_result(False, "Storage verification failed", {"output": result.stdout, "error": result.stderr})
except FileNotFoundError:
print_result(False, "Radicale not installed")
except subprocess.TimeoutExpired:
print_result(False, "Storage verification timed out")
except Exception as e:
print_result(False, f"Failed to verify storage: {e}")
def main():
import argparse
parser = argparse.ArgumentParser(description="Radicale server management")
subparsers = parser.add_subparsers(dest="command", required=True)
# Status
subparsers.add_parser("status", help="Check Radicale status")
# Config
config_parser = subparsers.add_parser("config", help="Configuration management")
config_sub = config_parser.add_subparsers(dest="config_command", required=True)
config_sub.add_parser("show", help="Show configuration")
config_sub.add_parser("validate", help="Validate configuration")
# Users
users_parser = subparsers.add_parser("users", help="User management")
users_sub = users_parser.add_subparsers(dest="users_command", required=True)
users_list = users_sub.add_parser("list", help="List users")
users_list.add_argument("--users-file", help="Custom users file path")
users_add = users_sub.add_parser("add", help="Add user")
users_add.add_argument("--username", required=True, help="Username")
users_add.add_argument("--encryption", choices=["sha512", "sha256", "bcrypt", "md5", "plain"],
default="sha512", help="Password encryption")
users_add.add_argument("--users-file", help="Custom users file path")
users_remove = users_sub.add_parser("remove", help="Remove user")
users_remove.add_argument("--username", required=True, help="Username")
users_remove.add_argument("--users-file", help="Custom users file path")
# Storage
storage_parser = subparsers.add_parser("storage", help="Storage management")
storage_sub = storage_parser.add_subparsers(dest="storage_command", required=True)
storage_verify = storage_sub.add_parser("verify", help="Verify storage")
storage_verify.add_argument("--storage-path", help="Custom storage path")
args = parser.parse_args()
# Dispatch
if args.command == "config":
globals()[f"cmd_config_{args.config_command}"](args)
elif args.command == "users":
globals()[f"cmd_users_{args.users_command}"](args)
elif args.command == "storage":
globals()[f"cmd_storage_{args.storage_command}"](args)
else:
globals()[f"cmd_{args.command}"](args)
if __name__ == "__main__":
main()
```
### scripts/todos.py
```python
#!/usr/bin/env python3
"""Todo/Task operations for CalDAV."""
import sys
from pathlib import Path
from datetime import datetime, date
from typing import Optional
# Add scripts dir to path
sys.path.insert(0, str(Path(__file__).parent))
from utils import get_client, print_result, parse_datetime
def find_calendar(client, calendar_name: str):
"""Find calendar by name or ID."""
principal = client.principal()
for cal in principal.calendars():
cal_id = str(cal.url).rstrip("/").split("/")[-1]
if cal_id == calendar_name or cal.name == calendar_name:
return cal
return None
def format_todo(todo) -> dict:
"""Format todo for display."""
data = {
"uid": todo.icalendar_component.get("uid", "unknown"),
"url": str(todo.url) if todo.url else None,
}
vtodo = todo.icalendar_component
# Summary
if vtodo.get("summary"):
data["summary"] = str(vtodo["summary"])
# Description
if vtodo.get("description"):
data["description"] = str(vtodo["description"])
# Status
if vtodo.get("status"):
data["status"] = str(vtodo["status"])
data["completed"] = vtodo["status"] == "COMPLETED"
# Priority
if vtodo.get("priority"):
data["priority"] = int(vtodo["priority"])
# Due date
if vtodo.get("due"):
due = vtodo["due"].dt
if hasattr(due, "isoformat"):
data["due"] = due.isoformat()
else:
data["due"] = str(due)
# Completed date
if vtodo.get("completed"):
completed = vtodo["completed"].dt
if hasattr(completed, "isoformat"):
data["completed_date"] = completed.isoformat()
else:
data["completed_date"] = str(completed)
# Categories
if vtodo.get("categories"):
cats = vtodo["categories"]
if isinstance(cats, str):
data["categories"] = [c.strip() for c in cats.split(",")]
elif isinstance(cats, list):
data["categories"] = [str(c) for c in cats]
# Check if overdue
if vtodo.get("due") and not data.get("completed"):
due = vtodo["due"].dt
now = datetime.now() if hasattr(due, "hour") else date.today()
if due < now:
data["overdue"] = True
return data
def cmd_list(args):
"""List todos."""
client = get_client()
principal = client.principal()
all_todos = []
if args.calendar:
calendars = [find_calendar(client, args.calendar)]
calendars = [c for c in calendars if c]
else:
calendars = principal.calendars()
for cal in calendars:
if not cal:
continue
try:
todos = cal.todos()
for todo in todos:
todo_data = format_todo(todo)
todo_data["calendar"] = cal.name
# Filter by completed status
if not args.completed and todo_data.get("completed"):
continue
# Filter by overdue
if args.overdue and not todo_data.get("overdue"):
continue
all_todos.append(todo_data)
except Exception as e:
# Calendar might not support todos
pass
# Sort by due date, then priority
def sort_key(t):
due = t.get("due", "")
priority = t.get("priority", 5) or 5
return (due or "zzz", priority)
all_todos.sort(key=sort_key)
print_result(True, f"Found {len(all_todos)} todo(s)", {"todos": all_todos})
def cmd_create(args):
"""Create a new todo."""
client = get_client()
calendar = find_calendar(client, args.calendar)
if not calendar:
print_result(False, f"Calendar '{args.calendar}' not found")
return
try:
kwargs = {
"summary": args.summary,
}
if args.due:
due = parse_datetime(args.due)
# If only date provided, use it as date (not datetime)
if "T" not in args.due and " " not in args.due:
due = due.date() if hasattr(due, "date") else due
kwargs["due"] = due
if args.description:
kwargs["description"] = args.description
if args.priority:
kwargs["priority"] = args.priority
if args.categories:
kwargs["categories"] = [c.strip() for c in args.categories.split(",")]
todo = calendar.save_todo(**kwargs)
todo_data = format_todo(todo)
print_result(True, "Todo created", todo_data)
except Exception as e:
print_result(False, f"Failed to create todo: {e}")
def cmd_complete(args):
"""Mark a todo as complete."""
client = get_client()
todo = None
# Find todo
if args.calendar:
calendar = find_calendar(client, args.calendar)
if calendar:
try:
todo = calendar.todo_by_uid(args.uid)
except Exception:
pass
if not todo:
principal = client.principal()
for cal in principal.calendars():
try:
todo = cal.todo_by_uid(args.uid)
break
except Exception:
pass
if not todo:
print_result(False, f"Todo '{args.uid}' not found")
return
try:
# Mark complete
vtodo = todo.icalendar_component
vtodo["status"] = "COMPLETED"
vtodo["completed"] = datetime.now()
todo.save()
todo_data = format_todo(todo)
print_result(True, "Todo marked as complete", todo_data)
except Exception as e:
print_result(False, f"Failed to complete todo: {e}")
def cmd_delete(args):
"""Delete a todo."""
client = get_client()
todo = None
# Find todo
if args.calendar:
calendar = find_calendar(client, args.calendar)
if calendar:
try:
todo = calendar.todo_by_uid(args.uid)
except Exception:
pass
if not todo:
principal = client.principal()
for cal in principal.calendars():
try:
todo = cal.todo_by_uid(args.uid)
break
except Exception:
pass
if not todo:
print_result(False, f"Todo '{args.uid}' not found")
return
if not args.force:
try:
confirm = input(f"Delete todo '{args.uid}'? [y/N] ")
if confirm.lower() != "y":
print_result(False, "Cancelled")
return
except EOFError:
print_result(False, "Use --force to skip confirmation")
return
try:
todo.delete()
print_result(True, f"Todo '{args.uid}' deleted")
except Exception as e:
print_result(False, f"Failed to delete todo: {e}")
def main():
import argparse
parser = argparse.ArgumentParser(description="Todo operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# List
list_cmd = subparsers.add_parser("list", help="List todos")
list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
list_cmd.add_argument("--completed", action="store_true", help="Show completed")
list_cmd.add_argument("--overdue", action="store_true", help="Show overdue only")
# Create
create = subparsers.add_parser("create", help="Create a todo")
create.add_argument("--calendar", "-c", required=True, help="Target calendar")
create.add_argument("--summary", "-s", required=True, help="Todo title")
create.add_argument("--due", help="Due date")
create.add_argument("--description", "-d", help="Description")
create.add_argument("--priority", type=int, help="Priority (1-9, 1=highest)")
create.add_argument("--categories", help="Comma-separated categories")
# Complete
complete = subparsers.add_parser("complete", help="Mark todo complete")
complete.add_argument("--uid", required=True, help="Todo UID")
complete.add_argument("--calendar", "-c", help="Calendar")
# Delete
delete = subparsers.add_parser("delete", help="Delete a todo")
delete.add_argument("--uid", required=True, help="Todo UID")
delete.add_argument("--calendar", "-c", help="Calendar")
delete.add_argument("--force", action="store_true", help="Skip confirmation")
args = parser.parse_args()
globals()[f"cmd_{args.command}"](args)
if __name__ == "__main__":
main()
```
### scripts/utils.py
```python
#!/usr/bin/env python3
"""Shared utilities for CalDAV operations."""
import os
import json
import argparse
from pathlib import Path
from typing import Optional, Dict, Any
# Config file locations
CONFIG_PATHS = [
Path.home() / ".config" / "caldav" / "config.json",
Path("/etc/caldav/config.json"),
]
def load_config() -> Dict[str, Any]:
"""Load configuration from file or environment."""
config = {}
# Try config files
for config_path in CONFIG_PATHS:
if config_path.exists():
with open(config_path) as f:
config.update(json.load(f))
break
# Environment overrides
if os.environ.get("CALDAV_URL"):
config["url"] = os.environ["CALDAV_URL"]
if os.environ.get("CALDAV_USER"):
config["username"] = os.environ["CALDAV_USER"]
if os.environ.get("CALDAV_PASSWORD"):
config["password"] = os.environ["CALDAV_PASSWORD"]
return config
def get_client():
"""Get authenticated CalDAV client."""
try:
from caldav import DAVClient
except ImportError:
print("Error: caldav library not installed.")
print("Install with: pip install caldav")
raise SystemExit(1)
config = load_config()
if not config.get("url"):
print("Error: No CalDAV URL configured.")
print("Set CALDAV_URL environment variable or create config.json")
raise SystemExit(1)
return DAVClient(
url=config.get("url"),
username=config.get("username"),
password=config.get("password"),
)
def format_datetime(dt_str: str) -> str:
"""Format datetime string for display."""
# Handle various formats
if "T" in dt_str:
if dt_str.endswith("Z"):
return dt_str.replace("Z", " UTC")
return dt_str
return dt_str
def parse_datetime(dt_str: str):
"""Parse datetime string to datetime object."""
from datetime import datetime
# Try various formats
formats = [
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
]
for fmt in formats:
try:
return datetime.strptime(dt_str, fmt)
except ValueError:
continue
raise ValueError(f"Unable to parse datetime: {dt_str}")
def print_result(success: bool, message: str, data: Optional[Dict] = None):
"""Print result in consistent format."""
import json
result = {"success": success, "message": message}
if data:
result["data"] = data
print(json.dumps(result, indent=2, default=str))
class CalendarCommand:
"""Base class for calendar commands."""
def __init__(self):
self.parser = argparse.ArgumentParser(description="Calendar operations")
subparsers = self.parser.add_subparsers(dest="command", required=True)
# List
subparsers.add_parser("list", help="List all calendars")
# Create
create = subparsers.add_parser("create", help="Create a new calendar")
create.add_argument("--name", required=True, help="Calendar display name")
create.add_argument("--id", help="Calendar ID (URL-safe)")
create.add_argument("--description", help="Calendar description")
create.add_argument(
"--color", help="Calendar color (hex, e.g., #FF0000)"
)
# Delete
delete = subparsers.add_parser("delete", help="Delete a calendar")
delete.add_argument("--id", required=True, help="Calendar ID or name")
delete.add_argument("--force", action="store_true", help="Skip confirmation")
# Info
info = subparsers.add_parser("info", help="Get calendar info")
info.add_argument("--id", required=True, help="Calendar ID or name")
# Export
export = subparsers.add_parser("export", help="Export calendar to ICS")
export.add_argument("--id", required=True, help="Calendar ID or name")
export.add_argument("--output", "-o", help="Output file path")
# Import
imp = subparsers.add_parser("import", help="Import ICS file")
imp.add_argument("--calendar", required=True, help="Target calendar ID or name")
imp.add_argument("--file", "-f", required=True, help="ICS file to import")
def run(self):
args = self.parser.parse_args()
getattr(self, f"cmd_{args.command}")(args)
class EventCommand:
"""Base class for event commands."""
def __init__(self):
self.parser = argparse.ArgumentParser(description="Event operations")
subparsers = self.parser.add_subparsers(dest="command", required=True)
# List
list_cmd = subparsers.add_parser("list", help="List events")
list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
list_cmd.add_argument("--start", help="Start date (YYYY-MM-DD)")
list_cmd.add_argument("--end", help="End date (YYYY-MM-DD)")
list_cmd.add_argument("--limit", type=int, default=50, help="Max results")
# Create
create = subparsers.add_parser("create", help="Create an event")
create.add_argument("--calendar", "-c", required=True, help="Target calendar")
create.add_argument("--summary", "-s", required=True, help="Event title")
create.add_argument("--start", required=True, help="Start datetime")
create.add_argument("--end", help="End datetime")
create.add_argument("--allday", action="store_true", help="All-day event")
create.add_argument("--description", "-d", help="Event description")
create.add_argument("--location", "-l", help="Event location")
create.add_argument("--rrule", help="Recurrence rule (e.g., FREQ=WEEKLY)")
create.add_argument("--uid", help="Custom UID")
create.add_argument("--timezone", help="Timezone (e.g., America/New_York)")
# Update
update = subparsers.add_parser("update", help="Update an event")
update.add_argument("--uid", required=True, help="Event UID")
update.add_argument("--calendar", "-c", help="Calendar (if known)")
update.add_argument("--summary", "-s", help="New title")
update.add_argument("--start", help="New start datetime")
update.add_argument("--end", help="New end datetime")
update.add_argument("--description", "-d", help="New description")
update.add_argument("--location", "-l", help="New location")
update.add_argument("--status", choices=["CONFIRMED", "TENTATIVE", "CANCELLED"])
# Delete
delete = subparsers.add_parser("delete", help="Delete an event")
delete.add_argument("--uid", required=True, help="Event UID")
delete.add_argument("--calendar", "-c", help="Calendar (if known)")
delete.add_argument("--force", action="store_true", help="Skip confirmation")
# Search
search = subparsers.add_parser("search", help="Search events")
search.add_argument("--query", "-q", required=True, help="Search query")
search.add_argument("--calendar", "-c", help="Filter by calendar")
# Get
get = subparsers.add_parser("get", help="Get event by UID")
get.add_argument("--uid", required=True, help="Event UID")
get.add_argument("--calendar", "-c", help="Calendar (if known)")
def run(self):
args = self.parser.parse_args()
getattr(self, f"cmd_{args.command}")(args)
class TodoCommand:
"""Base class for todo commands."""
def __init__(self):
self.parser = argparse.ArgumentParser(description="Todo operations")
subparsers = self.parser.add_subparsers(dest="command", required=True)
# List
list_cmd = subparsers.add_parser("list", help="List todos")
list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
list_cmd.add_argument("--completed", action="store_true", help="Show completed")
list_cmd.add_argument("--overdue", action="store_true", help="Show overdue only")
# Create
create = subparsers.add_parser("create", help="Create a todo")
create.add_argument("--calendar", "-c", required=True, help="Target calendar")
create.add_argument("--summary", "-s", required=True, help="Todo title")
create.add_argument("--due", help="Due date")
create.add_argument("--description", "-d", help="Description")
create.add_argument("--priority", type=int, help="Priority (1-9)")
create.add_argument("--categories", help="Comma-separated categories")
# Complete
complete = subparsers.add_parser("complete", help="Mark todo complete")
complete.add_argument("--uid", required=True, help="Todo UID")
complete.add_argument("--calendar", "-c", help="Calendar")
# Delete
delete = subparsers.add_parser("delete", help="Delete a todo")
delete.add_argument("--uid", required=True, help="Todo UID")
delete.add_argument("--calendar", "-c", help="Calendar")
delete.add_argument("--force", action="store_true", help="Skip confirmation")
def run(self):
args = self.parser.parse_args()
getattr(self, f"cmd_{args.command}")(args)
```