This commit is contained in:
Bendt
2025-12-18 22:11:47 -05:00
parent 0ed7800575
commit a41d59e529
26 changed files with 4187 additions and 373 deletions

332
src/services/khal/client.py Normal file
View File

@@ -0,0 +1,332 @@
"""Khal CLI client for calendar operations.
This module provides a client that uses the khal CLI tool to interact with
calendar data stored in vdir format.
"""
import subprocess
import logging
from datetime import datetime, date, timedelta
from typing import Optional, List
from src.calendar.backend import CalendarBackend, Event
logger = logging.getLogger(__name__)
class KhalClient(CalendarBackend):
"""Calendar backend using khal CLI."""
def __init__(self, config_path: Optional[str] = None):
"""Initialize the Khal client.
Args:
config_path: Optional path to khal config file
"""
self.config_path = config_path
def _run_khal(
self, args: List[str], capture_output: bool = True
) -> subprocess.CompletedProcess:
"""Run a khal command.
Args:
args: Command arguments (after 'khal')
capture_output: Whether to capture stdout/stderr
Returns:
CompletedProcess result
"""
cmd = ["khal"] + args
if self.config_path:
cmd.extend(["-c", self.config_path])
logger.debug(f"Running khal command: {' '.join(cmd)}")
return subprocess.run(
cmd,
capture_output=capture_output,
text=True,
)
def _parse_event_line(
self, line: str, day_header_date: Optional[date] = None
) -> Optional[Event]:
"""Parse a single event line from khal list output.
Expected format: title|start-time|end-time|start|end|location|uid|description|organizer|url|categories|status|recurring
Args:
line: The line to parse
day_header_date: Current day being parsed (from day headers)
Returns:
Event if successfully parsed, None otherwise
"""
# Skip empty lines and day headers
if not line or "|" not in line:
return None
parts = line.split("|")
if len(parts) < 5:
return None
try:
title = parts[0].strip()
start_str = parts[3].strip() # Full datetime
end_str = parts[4].strip() # Full datetime
location = parts[5].strip() if len(parts) > 5 else ""
uid = parts[6].strip() if len(parts) > 6 else ""
description = parts[7].strip() if len(parts) > 7 else ""
organizer = parts[8].strip() if len(parts) > 8 else ""
url = parts[9].strip() if len(parts) > 9 else ""
categories = parts[10].strip() if len(parts) > 10 else ""
status = parts[11].strip() if len(parts) > 11 else ""
recurring_symbol = parts[12].strip() if len(parts) > 12 else ""
# Parse datetimes (format: YYYY-MM-DD HH:MM)
start = datetime.strptime(start_str, "%Y-%m-%d %H:%M")
end = datetime.strptime(end_str, "%Y-%m-%d %H:%M")
# Check for all-day events (typically start at 00:00 and end at 00:00 next day)
all_day = (
start.hour == 0
and start.minute == 0
and end.hour == 0
and end.minute == 0
and (end.date() - start.date()).days >= 1
)
# Check if event is recurring (repeat symbol is typically a loop arrow)
recurring = bool(recurring_symbol)
return Event(
uid=uid or f"{title}_{start_str}",
title=title,
start=start,
end=end,
location=location,
description=description,
organizer=organizer,
url=url,
categories=categories,
status=status,
all_day=all_day,
recurring=recurring,
)
except (ValueError, IndexError) as e:
logger.warning(f"Failed to parse event line '{line}': {e}")
return None
def get_events(
self,
start_date: date,
end_date: date,
calendar: Optional[str] = None,
) -> List[Event]:
"""Get events in a date range.
Args:
start_date: Start of range (inclusive)
end_date: End of range (inclusive)
calendar: Optional calendar name to filter by
Returns:
List of events in the range, sorted by start time
"""
# Format dates for khal
start_str = start_date.strftime("%Y-%m-%d")
# Add one day to end_date to make it inclusive
end_dt = end_date + timedelta(days=1)
end_str = end_dt.strftime("%Y-%m-%d")
# Build command
# Format: title|start-time|end-time|start|end|location|uid|description|organizer|url|categories|status|recurring
format_str = "{title}|{start-time}|{end-time}|{start}|{end}|{location}|{uid}|{description}|{organizer}|{url}|{categories}|{status}|{repeat-symbol}"
args = ["list", "-f", format_str, start_str, end_str]
if calendar:
args.extend(["-a", calendar])
result = self._run_khal(args)
if result.returncode != 0:
logger.error(f"khal list failed: {result.stderr}")
return []
events = []
current_day: Optional[date] = None
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
# Check for day headers (e.g., "Today, 2025-12-18" or "Monday, 2025-12-22")
if ", " in line and "|" not in line:
try:
# Extract date from header
date_part = line.split(", ")[-1]
current_day = datetime.strptime(date_part, "%Y-%m-%d").date()
except ValueError:
pass
continue
event = self._parse_event_line(line, current_day)
if event:
events.append(event)
# Sort by start time
events.sort(key=lambda e: e.start)
return events
def get_event(self, uid: str) -> Optional[Event]:
"""Get a single event by UID.
Args:
uid: Event unique identifier
Returns:
Event if found, None otherwise
"""
# khal doesn't have a direct "get by uid" command
# We search for it instead
result = self._run_khal(["search", uid])
if result.returncode != 0 or not result.stdout.strip():
return None
# Parse the first result
# Search output format is different, so we need to handle it
lines = result.stdout.strip().split("\n")
if lines:
# For now, return None - would need more parsing
# This is a limitation of khal's CLI
return None
return None
def get_calendars(self) -> List[str]:
"""Get list of available calendar names.
Returns:
List of calendar names
"""
result = self._run_khal(["printcalendars"])
if result.returncode != 0:
logger.error(f"khal printcalendars failed: {result.stderr}")
return []
calendars = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line:
calendars.append(line)
return calendars
def create_event(
self,
title: str,
start: datetime,
end: datetime,
calendar: Optional[str] = None,
location: Optional[str] = None,
description: Optional[str] = None,
all_day: bool = False,
) -> Event:
"""Create a new event.
Args:
title: Event title
start: Start datetime
end: End datetime
calendar: Calendar to add event to
location: Event location
description: Event description
all_day: Whether this is an all-day event
Returns:
The created event
"""
# Build khal new command
# Format: khal new [-a calendar] start end title [:: description] [-l location]
if all_day:
start_str = start.strftime("%Y-%m-%d")
end_str = end.strftime("%Y-%m-%d")
else:
start_str = start.strftime("%Y-%m-%d %H:%M")
end_str = end.strftime("%H:%M") # End time only if same day
if end.date() != start.date():
end_str = end.strftime("%Y-%m-%d %H:%M")
args = ["new"]
if calendar:
args.extend(["-a", calendar])
if location:
args.extend(["-l", location])
args.extend([start_str, end_str, title])
if description:
args.extend(["::", description])
result = self._run_khal(args)
if result.returncode != 0:
raise RuntimeError(f"Failed to create event: {result.stderr}")
# Return a constructed event (khal doesn't return the created event)
return Event(
uid=f"new_{title}_{start.isoformat()}",
title=title,
start=start,
end=end,
location=location or "",
description=description or "",
calendar=calendar or "",
all_day=all_day,
)
def delete_event(self, uid: str) -> bool:
"""Delete an event.
Args:
uid: Event unique identifier
Returns:
True if deleted successfully
"""
# khal edit with --delete flag
# This is tricky because khal edit is interactive
# We might need to use khal's Python API directly for this
logger.warning("delete_event not fully implemented for khal CLI")
return False
def update_event(
self,
uid: str,
title: Optional[str] = None,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
location: Optional[str] = None,
description: Optional[str] = None,
) -> Optional[Event]:
"""Update an existing event.
Args:
uid: Event unique identifier
title: New title (if provided)
start: New start time (if provided)
end: New end time (if provided)
location: New location (if provided)
description: New description (if provided)
Returns:
Updated event if successful, None otherwise
"""
# khal edit is interactive, so this is limited via CLI
logger.warning("update_event not fully implemented for khal CLI")
return None