diff --git a/skills/google-calendar/SKILL.md b/skills/google-calendar/SKILL.md index 0dee2ed..180820b 100644 --- a/skills/google-calendar/SKILL.md +++ b/skills/google-calendar/SKILL.md @@ -33,30 +33,45 @@ OpenClaw assigns Google Calendar colors automatically based on event titles or e ## 🛠️ MCP Tools -You have access to 6 dedicated calendar tools: +You have access to 8 dedicated calendar tools: -1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, max_results=25)`: - - Lists appointments within a timeframe including start/end, color name, location, and description. - - `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. +1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`: + - Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description. + - `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences. + - `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`). - `query`: Text filter to search summaries and descriptions. 2. `calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`: - Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer. - Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`. -3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, category=None, color_id=None, description=None, location=None, all_day=False, check_conflicts=True)`: - - Adds a new appointment with automatic color coding and overlap detection. - - If conflicts or tight buffers are detected, a warning is returned in `conflict_warning`. - - For timed events: pass ISO datetime (e.g. `2026-09-20T14:30:00`). If `end_time` is omitted, defaults to 1 hour after start. - - For all-day events: set `all_day=True` and provide date `YYYY-MM-DD`. +3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`: + - Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection. + - **`recurrence`**: Standard RFC 5545 rule or shortcuts: + - `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"` (weekly Saturday market) + - `recurrence="RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z"` (daily except Sun until year end) + - Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`. + - **`show_as_busy` / `transparency`**: + - For **Regensburg city events, markets, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings! + - **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before). + - **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors. -4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, category=None, color_id=None, description=None, location=None)`: - - Updates fields or recolors an existing event. +4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`: + - Updates fields, recurrence, transparency, reminders, or recolors an existing event. + - `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence. -5. `calendar_delete_event(calendar="family"|"regensburg", event_id)`: +5. `calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`: - Deletes an event. + - `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence. -6. `calendar_get_event(calendar="family"|"regensburg", event_id)`: +6. `calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`: + - Moves an event atomically from one calendar to another (e.g. turning an interesting city event from Regensburg into a fixed family plan, or correcting a wrongly booked calendar). + +7. `calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`: + - Queries `freebusy` to find open, available time windows across calendars. + - Perfect for answering questions like: *"Wann haben wir diese Woche am Nachmittag 2 Stunden frei für einen Ausflug?"*. + +8. `calendar_get_event(calendar="family"|"regensburg", event_id)`: - Retrieves full event metadata. --- @@ -68,12 +83,21 @@ You have access to 6 dedicated calendar tools: - If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user: > *„⚠️ Achtung: Am Freitag um 17:30 Uhr hat Moritz schon 'Moritz Gartenparty Arbeit' (17:30 - 18:30). Dein Termin 'Spielplatz' würde sich damit überschneiden. Soll ich ihn trotzdem eintragen?“* -### 2. Smart Location & Google Maps Integration +### 2. Informational Events vs. Family Commitments (Transparency) +- When adding city events, festivals, or weekly markets to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`). +- This keeps the event clearly visible on the calendar while allowing family appointments to be scheduled in parallel without false conflict alerts. + +### 3. Recurring Series (RRULE) +- When the user asks to add recurring appointments (e.g. weekly markets, sports training, trash collection): + - Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts. + - Do NOT create manual cron scripts or multiple single events when a native Google Calendar recurrence series can be used. + +### 4. Smart Location & Google Maps Integration - If a place or venue is mentioned (e.g. *"Dr. Müller in Prüfening"*, *"Westbad"*): - Resolve the full address or ask the user, and store it in `location`. - This activates Google Maps departure alerts on their smartphone (*„Aufbruch in 15 Min. wegen Verkehr“*). -### 3. Proaktive Rückfragen & Checklisten (Mental Load Entlastung) +### 5. Proaktive Rückfragen & Checklisten (Mental Load Entlastung) - When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*): - Auto-generate or suggest a helpful checklist in `description`: ```text @@ -82,8 +106,8 @@ You have access to 6 dedicated calendar tools: - Versichertenkarte - 2x Wickelzeug & Schnuller ``` - - Ask proactively in Telegram: - > *„Termin eingetragen! 🏥 Soll ich noch eine Notiz oder Packliste (U-Heft, Impfpass, Wechselsachen) im Termin hinterlegen?“* + - Suggest a reminder (`reminder_minutes=[60, 1440]`): + > *„Termin eingetragen! 🏥 Ich habe eine Erinnerung 1 Stunde vorher eingerichtet. Soll ich noch eine Packliste im Termin hinterlegen?“* -### 4. Timezone & DateTime Precision +### 6. Timezone & DateTime Precision - All dates and times operate in **Europe/Berlin** (`CET`/`CEST`). diff --git a/skills/google-calendar/scripts/google_calendar.py b/skills/google-calendar/scripts/google_calendar.py index efd2830..2ade838 100755 --- a/skills/google-calendar/scripts/google_calendar.py +++ b/skills/google-calendar/scripts/google_calendar.py @@ -14,7 +14,7 @@ import sys import json import argparse from datetime import datetime, date, timedelta -from typing import Dict, Any, List, Optional, Tuple +from typing import Dict, Any, List, Optional, Tuple, Union from zoneinfo import ZoneInfo # Environment Configuration @@ -205,6 +205,88 @@ def parse_datetime_str(dt_str: str) -> datetime: return dt +def normalize_recurrence(val: Any) -> Optional[List[str]]: + """ + Normalize recurrence input into a list of valid RFC 5545 RRULE/EXRULE strings + accepted by Google Calendar API. + """ + if val is None or isinstance(val, bool): + return None + + raw_rules: List[str] = [] + if isinstance(val, str): + val_str = val.strip() + if not val_str: + return None + lower = val_str.lower() + if lower in ("weekly", "wöchentlich"): + return ["RRULE:FREQ=WEEKLY"] + elif lower in ("daily", "täglich"): + return ["RRULE:FREQ=DAILY"] + elif lower in ("monthly", "monatlich"): + return ["RRULE:FREQ=MONTHLY"] + elif lower in ("yearly", "jährlich", "annual"): + return ["RRULE:FREQ=YEARLY"] + elif lower in ("workdays", "werktags", "werktäglich"): + return ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"] + elif lower in ("weekend", "wochenende"): + return ["RRULE:FREQ=WEEKLY;BYDAY=SA,SU"] + + raw_rules = [r.strip() for r in val_str.split("\n") if r.strip()] + elif isinstance(val, (list, tuple)): + raw_rules = [str(r).strip() for r in val if str(r).strip()] + else: + return None + + if not raw_rules: + return None + + cleaned_rules = [] + for rule in raw_rules: + rule_upper = rule.upper() + if any(rule_upper.startswith(p) for p in ("RRULE:", "EXRULE:", "RDATE:", "EXDATE:")): + cleaned_rules.append(rule) + elif any(rule_upper.startswith(p) for p in ("FREQ=", "UNTIL=", "COUNT=", "INTERVAL=", "BYDAY=", "WKST=")): + cleaned_rules.append(f"RRULE:{rule}") + else: + cleaned_rules.append(f"RRULE:{rule}") + + return cleaned_rules if cleaned_rules else None + + +def normalize_reminders(val: Any) -> Optional[Dict[str, Any]]: + """ + Normalize reminder minutes into Google Calendar API reminders resource: + {"useDefault": False, "overrides": [{"method": "popup", "minutes": X}, ...]} + """ + if val is None: + return None + + minutes_list: List[int] = [] + if isinstance(val, int): + minutes_list = [val] + elif isinstance(val, (list, tuple)): + for item in val: + try: + minutes_list.append(int(item)) + except (ValueError, TypeError): + continue + elif isinstance(val, str): + val_str = val.strip() + if val_str: + for part in val_str.split(","): + part = part.strip() + try: + minutes_list.append(int(part)) + except (ValueError, TypeError): + continue + + return { + "useDefault": False, + "overrides": [{"method": "popup", "minutes": m} for m in minutes_list] + } + + def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]: """Format an event item into a clean representation including color labels.""" start = item.get("start", {}) @@ -217,6 +299,17 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]: color_id = item.get("colorId") color_label = COLOR_NAMES.get(color_id) if color_id else None + recurrence = item.get("recurrence") + recurring_event_id = item.get("recurringEventId") + is_recurring = bool(recurrence or recurring_event_id) + + transparency = item.get("transparency", "opaque") + show_as_busy = (transparency != "transparent") + + rem = item.get("reminders", {}) + use_default_rem = rem.get("useDefault", True) + reminder_minutes = [ov.get("minutes") for ov in rem.get("overrides", []) if "minutes" in ov] if not use_default_rem else None + return { "id": item.get("id"), "calendar": calendar_name, @@ -224,6 +317,15 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]: "start": start_str, "end": end_str, "all_day": is_all_day, + "is_recurring": is_recurring, + "recurrence": recurrence, + "recurring_event_id": recurring_event_id, + "show_as_busy": show_as_busy, + "transparency": transparency, + "reminders": { + "use_default": use_default_rem, + "reminder_minutes": reminder_minutes, + }, "color_id": color_id, "color_name": color_label, "description": item.get("description", ""), @@ -336,9 +438,11 @@ def list_events( time_min: Optional[str] = None, time_max: Optional[str] = None, query: Optional[str] = None, + category: Optional[str] = None, + color_id: Optional[str] = None, max_results: int = 25, ) -> Dict[str, Any]: - """List events from the specified calendar.""" + """List events from the specified calendar with optional query, category, and color filters.""" service = get_calendar_service() cal_id = resolve_calendar_id(calendar) @@ -355,7 +459,7 @@ def list_events( else: time_max_str = parse_datetime_str(time_max).isoformat() - params = { + params: Dict[str, Any] = { "calendarId": cal_id, "timeMin": time_min_str, "timeMax": time_max_str, @@ -369,12 +473,19 @@ def list_events( res = service.events().list(**params).execute() items = res.get("items", []) + # Filter by category / colorId if requested + target_color_id = color_id or (resolve_color_id("", explicit_category_or_color=category) if category else None) + if target_color_id: + items = [it for it in items if it.get("colorId") == target_color_id] + events = [format_event(item, calendar) for item in items] return { "calendar": calendar, "calendar_id": cal_id, "time_min": time_min_str, "time_max": time_max_str, + "filter_category": category, + "filter_color_id": target_color_id, "count": len(events), "events": events, } @@ -390,12 +501,17 @@ def create_event( all_day: bool = False, category: Optional[str] = None, color_id: Optional[str] = None, + recurrence: Optional[Any] = None, + show_as_busy: Optional[bool] = None, + transparency: Optional[str] = None, + reminder_minutes: Optional[Any] = None, + inclusive_end: bool = True, check_conflicts_flag: bool = True, buffer_minutes: int = 15, ) -> Dict[str, Any]: """ - Create a new event in the specified calendar with automatic color-coding - and conflict detection. + Create a new event in the specified calendar with automatic color-coding, + conflict detection, recurrence (RRULE), transparency, and custom reminders. """ if not summary: raise ValueError("Parameter 'summary' is required.") @@ -429,12 +545,37 @@ def create_event( if chosen_color_id: body["colorId"] = chosen_color_id + # Recurrence rules (RFC 5545 RRULE) + norm_rec = normalize_recurrence(recurrence) + if norm_rec: + body["recurrence"] = norm_rec + if conflict_info and (conflict_info["has_conflict"] or conflict_info["has_buffer_warning"]): + conflict_info["recurring_note"] = "Conflict check was performed on the initial occurrence only." + + # Event Transparency (Free vs. Busy) + if transparency: + body["transparency"] = transparency.lower().strip() + elif show_as_busy is False: + body["transparency"] = "transparent" + elif show_as_busy is True: + body["transparency"] = "opaque" + + # Custom Reminders + norm_rem = normalize_reminders(reminder_minutes) + if norm_rem is not None: + body["reminders"] = norm_rem + if all_day: start_date = start_time.split("T")[0] + dt_start = date.fromisoformat(start_date) if end_time: - end_date = end_time.split("T")[0] + end_date_raw = end_time.split("T")[0] + dt_end_raw = date.fromisoformat(end_date_raw) + if inclusive_end and dt_end_raw >= dt_start: + end_date = (dt_end_raw + timedelta(days=1)).isoformat() + else: + end_date = dt_end_raw.isoformat() else: - dt_start = date.fromisoformat(start_date) end_date = (dt_start + timedelta(days=1)).isoformat() body["start"] = {"date": start_date} @@ -478,15 +619,30 @@ def update_event( location: Optional[str] = None, category: Optional[str] = None, color_id: Optional[str] = None, + recurrence: Optional[Any] = None, + show_as_busy: Optional[bool] = None, + transparency: Optional[str] = None, + reminder_minutes: Optional[Any] = None, + inclusive_end: bool = True, + update_series: bool = False, ) -> Dict[str, Any]: - """Update an existing event.""" + """Update an existing event or series.""" if not event_id: raise ValueError("Parameter 'event_id' is required.") service = get_calendar_service() cal_id = resolve_calendar_id(calendar) - existing = service.events().get(calendarId=cal_id, eventId=event_id).execute() + target_id = event_id + if update_series: + try: + item_check = service.events().get(calendarId=cal_id, eventId=event_id).execute() + if item_check.get("recurringEventId"): + target_id = item_check["recurringEventId"] + except Exception: + pass + + existing = service.events().get(calendarId=cal_id, eventId=target_id).execute() if summary is not None: existing["summary"] = summary @@ -499,11 +655,36 @@ def update_event( if new_color: existing["colorId"] = new_color + if recurrence is not None: + if recurrence == [] or recurrence == "" or recurrence is False: + existing.pop("recurrence", None) + else: + norm_rec = normalize_recurrence(recurrence) + if norm_rec: + existing["recurrence"] = norm_rec + + if transparency is not None: + existing["transparency"] = transparency.lower().strip() + elif show_as_busy is not None: + existing["transparency"] = "opaque" if show_as_busy else "transparent" + + if reminder_minutes is not None: + norm_rem = normalize_reminders(reminder_minutes) + if norm_rem is not None: + existing["reminders"] = norm_rem + if start_time is not None: if "date" in existing.get("start", {}): - existing["start"]["date"] = start_time.split("T")[0] + start_date_str = start_time.split("T")[0] + existing["start"]["date"] = start_date_str + dt_start = date.fromisoformat(start_date_str) if end_time: - existing["end"]["date"] = end_time.split("T")[0] + end_date_raw = end_time.split("T")[0] + dt_end_raw = date.fromisoformat(end_date_raw) + if inclusive_end and dt_end_raw >= dt_start: + existing["end"]["date"] = (dt_end_raw + timedelta(days=1)).isoformat() + else: + existing["end"]["date"] = end_date_raw else: dt_start = parse_datetime_str(start_time) existing["start"] = { @@ -517,26 +698,187 @@ def update_event( "timeZone": DEFAULT_TIMEZONE_STR, } - updated = service.events().update(calendarId=cal_id, eventId=event_id, body=existing).execute() + updated = service.events().update(calendarId=cal_id, eventId=target_id, body=existing).execute() return { "status": "updated", "event": format_event(updated, calendar), + "updated_series": (target_id != event_id or update_series), } -def delete_event(calendar: str = "family", event_id: str = "") -> Dict[str, Any]: - """Delete an event from the specified calendar.""" +def delete_event( + calendar: str = "family", + event_id: str = "", + delete_series: bool = False, +) -> Dict[str, Any]: + """Delete an event or recurring series from the specified calendar.""" if not event_id: raise ValueError("Parameter 'event_id' is required.") service = get_calendar_service() cal_id = resolve_calendar_id(calendar) - service.events().delete(calendarId=cal_id, eventId=event_id).execute() + target_id = event_id + if delete_series: + try: + item = service.events().get(calendarId=cal_id, eventId=event_id).execute() + if item.get("recurringEventId"): + target_id = item["recurringEventId"] + except Exception: + pass + + service.events().delete(calendarId=cal_id, eventId=target_id).execute() return { "status": "deleted", "calendar": calendar, - "event_id": event_id, + "event_id": target_id, + "deleted_series": (target_id != event_id or delete_series), + } + + +def move_event( + event_id: str, + source_calendar: str = "family", + destination_calendar: str = "regensburg", +) -> Dict[str, Any]: + """Move an event atomically between calendars (e.g. from regensburg to family or vice versa).""" + if not event_id: + raise ValueError("Parameter 'event_id' is required.") + + service = get_calendar_service() + src_id = resolve_calendar_id(source_calendar) + dst_id = resolve_calendar_id(destination_calendar) + + moved = service.events().move( + calendarId=src_id, + eventId=event_id, + destination=dst_id, + ).execute() + + return { + "status": "moved", + "source_calendar": source_calendar, + "destination_calendar": destination_calendar, + "event": format_event(moved, destination_calendar), + } + + +def find_free_slots( + calendar: str = "family", + time_min: Optional[str] = None, + time_max: Optional[str] = None, + duration_minutes: int = 60, + day_start_hour: int = 8, + day_end_hour: int = 20, +) -> Dict[str, Any]: + """ + Find available free time windows between day_start_hour and day_end_hour + across specified calendar(s) using freebusy.query. + """ + service = get_calendar_service() + + now = datetime.now(DEFAULT_TZ) + if not time_min: + dt_start = now + else: + dt_start = parse_datetime_str(time_min) + + if not time_max: + dt_end = dt_start + timedelta(days=7) + else: + dt_end = parse_datetime_str(time_max) + + # Determine which calendars to query + cal_ids = [] + cal_lower = calendar.lower().strip() + if cal_lower in ("both", "alle", "all"): + if CALENDAR_ID_FAMILY: + cal_ids.append(CALENDAR_ID_FAMILY) + if CALENDAR_ID_REGENSBURG: + cal_ids.append(CALENDAR_ID_REGENSBURG) + else: + cal_ids.append(resolve_calendar_id(calendar)) + + body = { + "timeMin": dt_start.isoformat(), + "timeMax": dt_end.isoformat(), + "timeZone": DEFAULT_TIMEZONE_STR, + "items": [{"id": cid} for cid in cal_ids], + } + + fb_res = service.freebusy().query(body=body).execute() + cals_dict = fb_res.get("calendars", {}) + + busy_intervals = [] + for cid, cdata in cals_dict.items(): + for b in cdata.get("busy", []): + b_start = parse_datetime_str(b["start"]) + b_end = parse_datetime_str(b["end"]) + busy_intervals.append((b_start, b_end)) + + busy_intervals.sort(key=lambda x: x[0]) + + merged_busy = [] + for b_start, b_end in busy_intervals: + if not merged_busy: + merged_busy.append([b_start, b_end]) + else: + prev_start, prev_end = merged_busy[-1] + if b_start <= prev_end: + merged_busy[-1][1] = max(prev_end, b_end) + else: + merged_busy.append([b_start, b_end]) + + free_slots = [] + current_day = dt_start.date() + end_day = dt_end.date() + + while current_day <= end_day: + window_start = datetime(current_day.year, current_day.month, current_day.day, day_start_hour, 0, 0, tzinfo=DEFAULT_TZ) + window_end = datetime(current_day.year, current_day.month, current_day.day, day_end_hour, 0, 0, tzinfo=DEFAULT_TZ) + + eff_start = max(window_start, dt_start) + eff_end = min(window_end, dt_end) + + if eff_start < eff_end: + slot_pointer = eff_start + for b_start, b_end in merged_busy: + if b_end <= slot_pointer: + continue + if b_start >= eff_end: + break + + if b_start > slot_pointer: + gap_minutes = (b_start - slot_pointer).total_seconds() / 60.0 + if gap_minutes >= duration_minutes: + free_slots.append({ + "start": slot_pointer.isoformat(), + "end": b_start.isoformat(), + "duration_minutes": int(gap_minutes), + "date": current_day.isoformat(), + }) + slot_pointer = max(slot_pointer, b_end) + + if slot_pointer < eff_end: + gap_minutes = (eff_end - slot_pointer).total_seconds() / 60.0 + if gap_minutes >= duration_minutes: + free_slots.append({ + "start": slot_pointer.isoformat(), + "end": eff_end.isoformat(), + "duration_minutes": int(gap_minutes), + "date": current_day.isoformat(), + }) + + current_day += timedelta(days=1) + + return { + "calendar": calendar, + "search_min": dt_start.isoformat(), + "search_max": dt_end.isoformat(), + "requested_duration_minutes": duration_minutes, + "day_hours": f"{day_start_hour:02d}:00 - {day_end_hour:02d}:00", + "slots_found": len(free_slots), + "free_slots": free_slots, } @@ -604,7 +946,7 @@ def test_connection() -> Dict[str, Any]: TOOLS_MANIFEST = [ { "name": "calendar_list_events", - "description": "List upcoming appointments and events from the Google Calendar. Returns event title, start/end time, color category, location, and description.", + "description": "List upcoming appointments and events from the Google Calendar. Returns event title, start/end time, color category, location, recurrence, and description.", "inputSchema": { "type": "object", "properties": { @@ -625,6 +967,14 @@ TOOLS_MANIFEST = [ "type": "string", "description": "Optional search term to filter event titles and descriptions.", }, + "category": { + "type": "string", + "description": "Optional category filter: 'arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt'.", + }, + "color_id": { + "type": "string", + "description": "Optional colorId filter ('11' for Arzt, '5' for Mila, '9' for Moritz, etc.).", + }, "max_results": { "type": "integer", "description": "Maximum number of events to return (default 25).", @@ -663,7 +1013,7 @@ TOOLS_MANIFEST = [ }, { "name": "calendar_create_event", - "description": "Create a new appointment in Google Calendar. Supports automatic color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange, Haushalt=Grau), address location, prep checklists, and conflict warnings.", + "description": "Create a new appointment in Google Calendar. Supports automatic color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange, Haushalt=Grau), recurring series (RRULE), transparency (busy vs free), phone notification reminders, address location, checklists, and conflict warnings.", "inputSchema": { "type": "object", "required": ["summary", "start_time"], @@ -685,6 +1035,21 @@ TOOLS_MANIFEST = [ "type": "string", "description": "Optional end time in ISO format. If omitted for timed events, defaults to 1 hour after start_time.", }, + "recurrence": { + "description": "Optional recurrence rule(s) for repeating events (e.g. ['RRULE:FREQ=WEEKLY;BYDAY=SA'], 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z', or shortcuts like 'weekly', 'daily', 'monthly', 'workdays').", + }, + "show_as_busy": { + "type": "boolean", + "description": "Whether this event marks time as busy. Set False for informational city events, festivals, or notes so they don't block family calendar slots or cause false conflict warnings (default True).", + "default": True, + }, + "transparency": { + "type": "string", + "description": "Explicit Google transparency: 'opaque' (busy) or 'transparent' (free/available).", + }, + "reminder_minutes": { + "description": "Optional notification reminder minutes before event (e.g. [15, 60] or 1440 for 1 day before). Sends popup alert to phone.", + }, "category": { "type": "string", "description": "Optional category/person for color-coding: 'arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt'. If omitted, auto-detected from summary.", @@ -706,6 +1071,11 @@ TOOLS_MANIFEST = [ "description": "Whether the event lasts all day (default False).", "default": False, }, + "inclusive_end": { + "type": "boolean", + "description": "For all-day events with end_time date, whether the end date is inclusive (default True).", + "default": True, + }, "check_conflicts": { "type": "boolean", "description": "Whether to check for scheduling overlaps and attach warnings (default True).", @@ -716,7 +1086,7 @@ TOOLS_MANIFEST = [ }, { "name": "calendar_update_event", - "description": "Update an existing event in the Google Calendar (title, times, category/color, description, location).", + "description": "Update an existing event or recurring series in the Google Calendar (title, times, recurrence, transparency, reminders, category/color, description, location).", "inputSchema": { "type": "object", "required": ["event_id"], @@ -742,6 +1112,25 @@ TOOLS_MANIFEST = [ "type": "string", "description": "New end time in ISO format.", }, + "recurrence": { + "description": "Updated recurrence rule(s), or [] / empty string to remove recurrence.", + }, + "show_as_busy": { + "type": "boolean", + "description": "Whether this event marks time as busy (True) or free (False).", + }, + "transparency": { + "type": "string", + "description": "Explicit transparency: 'opaque' (busy) or 'transparent' (available).", + }, + "reminder_minutes": { + "description": "Updated reminder minutes list before event (e.g. [15, 60]).", + }, + "update_series": { + "type": "boolean", + "description": "If True and event is an occurrence of a series, updates the entire master series (default False).", + "default": False, + }, "category": { "type": "string", "description": "Category for color-coding ('arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt').", @@ -763,7 +1152,7 @@ TOOLS_MANIFEST = [ }, { "name": "calendar_delete_event", - "description": "Delete an event from the Google Calendar.", + "description": "Delete an event or recurring series from the Google Calendar.", "inputSchema": { "type": "object", "required": ["event_id"], @@ -777,6 +1166,72 @@ TOOLS_MANIFEST = [ "type": "string", "description": "The unique ID of the event to delete.", }, + "delete_series": { + "type": "boolean", + "description": "If True and the event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence (default False).", + "default": False, + }, + }, + }, + }, + { + "name": "calendar_move_event", + "description": "Move an event atomically between calendars (e.g. from 'regensburg' to 'family' or vice versa).", + "inputSchema": { + "type": "object", + "required": ["event_id"], + "properties": { + "event_id": { + "type": "string", + "description": "The unique ID of the event to move.", + }, + "source_calendar": { + "type": "string", + "description": "Source calendar ('family' or 'regensburg'). Default 'family'.", + "default": "family", + }, + "destination_calendar": { + "type": "string", + "description": "Destination calendar ('family' or 'regensburg'). Default 'regensburg'.", + "default": "regensburg", + }, + }, + }, + }, + { + "name": "calendar_find_free_slots", + "description": "Find available free time windows across calendars (family, regensburg, or both) using Google Calendar freebusy query.", + "inputSchema": { + "type": "object", + "properties": { + "calendar": { + "type": "string", + "description": "Which calendar(s) to check: 'family' (default), 'regensburg', or 'both'/'all'.", + "default": "family", + }, + "time_min": { + "type": "string", + "description": "Start of search timeframe in ISO format (defaults to now).", + }, + "time_max": { + "type": "string", + "description": "End of search timeframe in ISO format (defaults to 7 days ahead).", + }, + "duration_minutes": { + "type": "integer", + "description": "Minimum length of the free slot required in minutes (default 60).", + "default": 60, + }, + "day_start_hour": { + "type": "integer", + "description": "Earliest hour of the day to consider (default 8 for 08:00).", + "default": 8, + }, + "day_end_hour": { + "type": "integer", + "description": "Latest hour of the day to consider (default 20 for 20:00).", + "default": 20, + }, }, }, }, @@ -827,7 +1282,7 @@ def run_mcp_server(): "capabilities": {"tools": {}}, "serverInfo": { "name": "google-calendar-mcp", - "version": "1.1.0", + "version": "1.2.0", }, }, } @@ -858,6 +1313,8 @@ def run_mcp_server(): time_min=args.get("time_min"), time_max=args.get("time_max"), query=args.get("query"), + category=args.get("category"), + color_id=args.get("color_id"), max_results=args.get("max_results", 25), ) elif tool_name == "calendar_check_conflicts": @@ -878,6 +1335,11 @@ def run_mcp_server(): all_day=args.get("all_day", False), category=args.get("category"), color_id=args.get("color_id"), + recurrence=args.get("recurrence"), + show_as_busy=args.get("show_as_busy"), + transparency=args.get("transparency"), + reminder_minutes=args.get("reminder_minutes"), + inclusive_end=args.get("inclusive_end", True), check_conflicts_flag=args.get("check_conflicts", True), ) elif tool_name == "calendar_update_event": @@ -891,11 +1353,33 @@ def run_mcp_server(): location=args.get("location"), category=args.get("category"), color_id=args.get("color_id"), + recurrence=args.get("recurrence"), + show_as_busy=args.get("show_as_busy"), + transparency=args.get("transparency"), + reminder_minutes=args.get("reminder_minutes"), + inclusive_end=args.get("inclusive_end", True), + update_series=args.get("update_series", False), ) elif tool_name == "calendar_delete_event": data = delete_event( calendar=args.get("calendar", "family"), event_id=args.get("event_id", ""), + delete_series=args.get("delete_series", False), + ) + elif tool_name == "calendar_move_event": + data = move_event( + event_id=args.get("event_id", ""), + source_calendar=args.get("source_calendar", "family"), + destination_calendar=args.get("destination_calendar", "regensburg"), + ) + elif tool_name == "calendar_find_free_slots": + data = find_free_slots( + calendar=args.get("calendar", "family"), + time_min=args.get("time_min"), + time_max=args.get("time_max"), + duration_minutes=args.get("duration_minutes", 60), + day_start_hour=args.get("day_start_hour", 8), + day_end_hour=args.get("day_end_hour", 20), ) elif tool_name == "calendar_get_event": data = get_event( @@ -953,6 +1437,8 @@ def run_cli(): sp_list.add_argument("--min", dest="time_min", help="ISO start time") sp_list.add_argument("--max", dest="time_max", help="ISO end time") sp_list.add_argument("--query", "-q", help="Search text query") + sp_list.add_argument("--category", help="Category filter") + sp_list.add_argument("--color", dest="color_id", help="Color ID filter") sp_list.add_argument("--limit", "-n", type=int, default=25, help="Max results") # Conflicts @@ -962,6 +1448,13 @@ def run_cli(): sp_conf.add_argument("--calendar", "-c", default="family") sp_conf.add_argument("--buffer", type=int, default=15, help="Buffer minutes") + # Free slots + sp_slots = subparsers.add_parser("free-slots", help="Find free time windows") + sp_slots.add_argument("--calendar", "-c", default="family", help="'family', 'regensburg', or 'both'") + sp_slots.add_argument("--min", dest="time_min", help="ISO start time") + sp_slots.add_argument("--max", dest="time_max", help="ISO end time") + sp_slots.add_argument("--duration", type=int, default=60, help="Required duration in minutes") + # Create sp_create = subparsers.add_parser("create", help="Create event") sp_create.add_argument("summary", help="Event summary/title") @@ -969,14 +1462,24 @@ def run_cli(): sp_create.add_argument("--end", dest="end_time", help="End time") sp_create.add_argument("--calendar", "-c", default="family", help="'family' or 'regensburg'") sp_create.add_argument("--category", help="Category: arzt, mila, moritz, amelie, familie, haushalt") + sp_create.add_argument("--recurrence", "-r", help="RRULE recurrence (e.g. 'RRULE:FREQ=WEEKLY;BYDAY=SA' or 'weekly')") + sp_create.add_argument("--free", "--transparent", dest="transparent", action="store_true", help="Mark event as free/transparent") + sp_create.add_argument("--reminder", dest="reminder_minutes", type=int, action="append", help="Reminder in minutes before event") sp_create.add_argument("--description", "-d", help="Description") sp_create.add_argument("--location", "-l", help="Location") sp_create.add_argument("--all-day", action="store_true", help="All day event") + # Move + sp_move = subparsers.add_parser("move", help="Move event between calendars") + sp_move.add_argument("event_id", help="Event ID") + sp_move.add_argument("--from", dest="source_calendar", default="family", help="Source calendar") + sp_move.add_argument("--to", dest="destination_calendar", default="regensburg", help="Destination calendar") + # Delete sp_del = subparsers.add_parser("delete", help="Delete event") sp_del.add_argument("event_id", help="Event ID") sp_del.add_argument("--calendar", "-c", default="family", help="'family' or 'regensburg'") + sp_del.add_argument("--series", action="store_true", help="Delete entire recurring series") args = parser.parse_args() @@ -989,12 +1492,21 @@ def run_cli(): end_time=args.end_time, buffer_minutes=args.buffer, ), indent=2, ensure_ascii=False)) + elif args.command == "free-slots": + print(json.dumps(find_free_slots( + calendar=args.calendar, + time_min=args.time_min, + time_max=args.time_max, + duration_minutes=args.duration, + ), indent=2, ensure_ascii=False)) elif args.command == "list": print(json.dumps(list_events( calendar=args.calendar, time_min=args.time_min, time_max=args.time_max, query=args.query, + category=args.category, + color_id=args.color_id, max_results=args.limit, ), indent=2, ensure_ascii=False)) elif args.command == "create": @@ -1004,14 +1516,24 @@ def run_cli(): start_time=args.start_time, end_time=args.end_time, category=args.category, + recurrence=args.recurrence, + show_as_busy=not args.transparent, + reminder_minutes=args.reminder_minutes, description=args.description, location=args.location, all_day=args.all_day, ), indent=2, ensure_ascii=False)) + elif args.command == "move": + print(json.dumps(move_event( + event_id=args.event_id, + source_calendar=args.source_calendar, + destination_calendar=args.destination_calendar, + ), indent=2, ensure_ascii=False)) elif args.command == "delete": print(json.dumps(delete_event( calendar=args.calendar, event_id=args.event_id, + delete_series=args.series, ), indent=2, ensure_ascii=False)) else: parser.print_help()