WIP
This commit is contained in:
332
src/services/khal/client.py
Normal file
332
src/services/khal/client.py
Normal 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
|
||||
Reference in New Issue
Block a user