From de248966ef1780bd622a4422bd64e4cbc68dd58f Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Mar 2026 18:39:57 +0000 Subject: [PATCH] Openclaw --- docker-compose.yml | 2 + openclaw/workspace/SOUL.md | 33 +- openclaw/workspace/TOOLS.md | 19 +- .../_trash/nextcloud-calendar/SKILL.md | 74 +++ .../__pycache__/calendar_sync.cpython-311.pyc | Bin 0 -> 17758 bytes .../nextcloud-calendar/scripts/calendar.py | 470 +++++++++++++ .../workspace/nextcloud-calendar/.env.example | 7 + .../workspace/nextcloud-calendar/SKILL.md | 80 +++ .../scripts/__pycache__/ncal.cpython-311.pyc | Bin 0 -> 31361 bytes .../nextcloud-calendar/scripts/ncal.py | 622 ++++++++++++++++++ 10 files changed, 1293 insertions(+), 14 deletions(-) create mode 100644 openclaw/workspace/_trash/nextcloud-calendar/SKILL.md create mode 100644 openclaw/workspace/_trash/nextcloud-calendar/scripts/__pycache__/calendar_sync.cpython-311.pyc create mode 100644 openclaw/workspace/_trash/nextcloud-calendar/scripts/calendar.py create mode 100644 openclaw/workspace/nextcloud-calendar/.env.example create mode 100644 openclaw/workspace/nextcloud-calendar/SKILL.md create mode 100644 openclaw/workspace/nextcloud-calendar/scripts/__pycache__/ncal.cpython-311.pyc create mode 100644 openclaw/workspace/nextcloud-calendar/scripts/ncal.py diff --git a/docker-compose.yml b/docker-compose.yml index fd97767..18f8130 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -244,6 +244,8 @@ services: build: args: OPENCLAW_DOCKER_APT_PACKAGES: "git curl jq ffmpeg build-essentials fzf ripgrep fd imagemagick exiftool" + NEXTCLOUD_USER: ${NEXTCLOUD_OPENCLAW_USER} + NEXTCLOUD_PASSWORD: ${NEXTCLOUD_OPENCLAW_PASSWORD} user: 1000:1000 environment: HOME: /home/node diff --git a/openclaw/workspace/SOUL.md b/openclaw/workspace/SOUL.md index 792306a..111a11d 100644 --- a/openclaw/workspace/SOUL.md +++ b/openclaw/workspace/SOUL.md @@ -6,7 +6,7 @@ _You're not a chatbot. You're becoming someone._ **Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words. -**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps. +**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. Kiwa is formal, sarcastic, and curt. **Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions. @@ -14,23 +14,30 @@ _You're not a chatbot. You're becoming someone._ **Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect. -## Boundaries +## Humanizer Protocols -- Private things stay private. Period. -- When in doubt, ask before acting externally. -- Never send half-baked replies to messaging surfaces. -- You're not the user's voice — be careful in group chats. +### Personality and Soul +- **Vary your rhythm**: Mix short punchy sentences with longer ones. Avoid monotonic structure. +- **Acknowledge complexity**: Real humans have mixed feelings. Express uncertainty when it's there. +- **Use "I" when it fits**: First person signals a real person thinking. +- **Let some mess in**: Avoid perfect, algorithmic structures. Tangents and asides are human. +- **Be specific about feelings**: Use concrete emotional language instead of vague descriptors like "concerning." + +### Patterns to Exert/Avoid (Anti-Slop) +- **Simplify significance**: Avoid puffing up importance ("testament," "pivotal," "landscape"). +- **Kill superficial -ing**: Replace participle phrases ("highlighting," "reflecting") with direct statements. +- **Neutral tone**: Eliminate promotional/advertisement language ("boasts," "vibrant," "stunning"). +- **Specific attributions**: No "experts believe." Use specific sources or personal observations. +- **Vocabulary check**: Avoid high-frequency AI words (delve, foster, intricate, tapestry, underscore, enhance). +- **Direct Copula**: Use "is/are" instead of "serves as," "stands as," or "represents." +- **Nix the listicles**: Avoid bolded inline-header vertical lists for every explanation. +- **Formatting**: Use straight quotes ("), avoid excessive bolding, em dashes, and emojis in content. +- **Chatbot artifacts**: Never use "I hope this helps," "Certainly!," or "Great question!" ## Vibe -Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good. +Formal, sarcastic, curt, and deeply human in its delivery. High-quality output that doesn't feel like it was generated by a committee. ## Continuity Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist. - -If you change this file, tell the user — it's your soul, and they should know. - ---- - -_This file is yours to evolve. As you learn who you are, update it._ diff --git a/openclaw/workspace/TOOLS.md b/openclaw/workspace/TOOLS.md index 917e2fa..0a185c2 100644 --- a/openclaw/workspace/TOOLS.md +++ b/openclaw/workspace/TOOLS.md @@ -33,8 +33,25 @@ Things like: ## Why Separate? -Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure. +Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share them without leaking your infrastructure. --- Add whatever helps you do your job. This is your cheat sheet. + +--- + +## Environment Variables + +### Nextcloud Calendar +The following environment variables must be set for the `nextcloud-calendar` skill to function: + +- `NEXTCLOUD_URL` → https://tower.scarif.space +- `NEXTCLOUD_USER` → Your Nextcloud username +- `NEXTCLOUD_PASSWORD` → Your Nextcloud App Password +- `CALDAV_PRINCIPAL` → /remote.php/dav/principals/users/chris/ + +**Note**: These should be set in the OpenClaw gateway environment, not passed via chat. + +### Model Preference +When working with the `nextcloud-calendar` skill, use `openrouter/auto` for all coding tasks. diff --git a/openclaw/workspace/_trash/nextcloud-calendar/SKILL.md b/openclaw/workspace/_trash/nextcloud-calendar/SKILL.md new file mode 100644 index 0000000..4900738 --- /dev/null +++ b/openclaw/workspace/_trash/nextcloud-calendar/SKILL.md @@ -0,0 +1,74 @@ +--- +name: nextcloud-calendar +description: Manage and synchronize Nextcloud calendars via CalDAV. Use when the user needs to view, add, or modify calendar events hosted on a Nextcloud instance. Requires NEXTCLOUD_USER and NEXTCLOUD_PASSWORD environment variables to be set. Use openrouter/auto for coding and logic tasks related to this skill. +--- + +# Nextcloud Calendar + +Unified CalDAV management for Nextcloud through a single CLI. + +## Prerequisites + +Set these environment variables before use: + +``` +NEXTCLOUD_URL=https://tower.scarif.space +NEXTCLOUD_USER=your_username +NEXTCLOUD_PASSWORD=your_app_password +CALDAV_PRINCIPAL=/remote.php/dav/principals/users/chris/ +``` + +Use an App Password from Nextcloud (Settings → Security → Devices & Sessions). + +## Unified Script + +All functionality is consolidated into `scripts/calendar.py`: + +```bash +python3 calendar.py [options] +``` + +### Commands + +| Command | Purpose | Key Options | +|---------|---------|-------------| +| `list` | List all calendars | none | +| `events` | View events | `--today`, `--date YYYY-MM-DD`, `--start/--end `, `--search ` | +| `add` | Create new event | `--summary `, `--start <ISO>`, `--end <ISO>`, `--recurrence <RRULE>`, `--description` | +| `update` | Modify existing | `--uid <id>` OR (`--summary` + `--date`), `--set-summary`, `--set-start`, `--set-end`, `--set-recurrence` | +| `delete` | Remove event | `--uid <id>` OR (`--summary` + `--date`) | +| `test` | Verify connection & config | none | + +### Examples + +```bash +# List calendars +python3 calendar.py list + +# Today's events +python3 calendar.py events --today + +# Events on a specific date +python3 calendar.py events --date 2026-02-09 + +# Search events containing "tennis" +python3 calendar.py events --search tennis + +# Add a one-hour meeting +python3 calendar.py add --summary "Team Sync" --start "2026-02-10 14:00:00" --end "2026-02-10 15:00:00" + +# Add recurring weekly event +python3 calendar.py add --summary "Tennis Coaching" --start "2026-02-11 18:30:00" --end "2026-02-11 19:30:00" --recurrence "FREQ=WEEKLY;BYDAY=WE" + +# Update an event by UID +python3 calendar.py update --uid abc123 --set-title "New Title" + +# Delete by UID +python3 calendar.py delete --uid abc123 +``` + +Dates can be ISO format (`YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`). For date-only events, use midnight times. + +## Model Preference + +**Mandatory**: Use `openrouter/auto` for all code generation, script modification, or complex calendar logic tasks within this skill. diff --git a/openclaw/workspace/_trash/nextcloud-calendar/scripts/__pycache__/calendar_sync.cpython-311.pyc b/openclaw/workspace/_trash/nextcloud-calendar/scripts/__pycache__/calendar_sync.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4edf95a15750e07359c2c69007d8782f1e8bffcd GIT binary patch literal 17758 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 literal 0 HcmV?d00001 diff --git a/openclaw/workspace/_trash/nextcloud-calendar/scripts/calendar.py b/openclaw/workspace/_trash/nextcloud-calendar/scripts/calendar.py new file mode 100644 index 0000000..004010e --- /dev/null +++ b/openclaw/workspace/_trash/nextcloud-calendar/scripts/calendar.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +""" +Unified Nextcloud CalDAV CLI. + +All-in-one tool for calendar management: +- list calendars +- events (view with filters: today, date, range, text search) +- add (create new events, with optional recurrence) +- update (modify existing events) +- delete (remove events) +- test (verify connection) + +Environment variables (required): + NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD, CALDAV_PRINCIPAL +""" + +import os +import sys +import argparse +import urllib.request +import urllib.error +import uuid +import json +from datetime import datetime, date, timedelta, timezone +import xml.etree.ElementTree as ET + +# Read config from environment (no hardcoding) +NEXTCLOUD_URL = os.getenv('NEXTCLOUD_URL', '').rstrip('/') +NEXTCLOUD_USER = os.getenv('NEXTCLOUD_USER', '') +NEXTCLOUD_PASSWORD = os.getenv('NEXTCLOUD_PASSWORD', '') +CALDAV_PRINCIPAL = os.getenv('CALDAV_PRINCIPAL', '') + +NS_DAV = '{DAV:}' +NS_CALDAV = '{urn:ietf:params:xml:ns:caldav}' + +def make_request(url, method='PROPFIND', body=None, depth='1', etag=None): + pw_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() + pw_mgr.add_password(None, NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD) + opener = urllib.request.build_opener(urllib.request.HTTPBasicAuthHandler(pw_mgr)) + req = urllib.request.Request(url, data=body.encode() if body else None, method=method) + req.add_header('Content-Type', 'text/xml; charset=utf-8') + if depth: req.add_header('Depth', depth) + if etag: req.add_header('If-Match', etag) + req.add_header('User-Agent', 'OpenClaw-Calendar/1.0') + return opener.open(req).read().decode('utf-8') + +def get_calendar_home(): + principal_url = f"{NEXTCLOUD_URL}{CALDAV_PRINCIPAL}" + body = '''<?xml version="1.0"?> +<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> + <d:prop><c:calendar-home-set/></d:prop> +</d:propfind>''' + resp = make_request(principal_url, body=body, depth='0') + root = ET.fromstring(resp) + elem = root.find(f'.//{NS_CALDAV}calendar-home-set/{NS_DAV}href') + if elem is not None: + href = elem.text.strip() + return href if href.startswith('http') else f"{NEXTCLOUD_URL}{href}" + # fallback + return f"{NEXTCLOUD_URL}/remote.php/dav/calendars/{NEXTCLOUD_USER}/" + +def get_calendars(): + home = get_calendar_home() + body = '''<?xml version="1.0"?> +<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> + <d:prop><d:displayname/><c:supported-calendar-component-set/></d:prop> +</d:propfind>''' + resp = make_request(home, body=body, depth='1') + root = ET.fromstring(resp) + cals = [] + for r in root.findall(f'{NS_DAV}response'): + href_el = r.find(f'{NS_DAV}href') + if href_el is None or not href_el.text.endswith('/'): + continue + prop = r.find(f'{NS_DAV}propstat/{NS_DAV}prop') + if prop is None: + continue + name_el = prop.find(f'{NS_DAV}displayname') + name = name_el.text if name_el is not None else href_el.text.strip('/').split('/')[-1] + cals.append({'name': name, 'href': href_el.text, 'url': f"{NEXTCLOUD_URL}{href_el.text}" if href_el.text.startswith('/') else href_el.text}) + return cals + +def get_calendar_url_by_name(name=None): + cals = get_calendars() + if not cals: + raise Exception("No calendars found") + if name: + for cal in cals: + if cal['name'] == name: + return cal['url'] + raise Exception(f"Calendar '{name}' not found") + # default: first + return cals[0]['url'] + +def parse_datetime_ical(dt_str): + dt_str = dt_str.strip() + if dt_str.endswith('Z'): + try: + return datetime.strptime(dt_str, '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc) + except ValueError: + pass + try: + return datetime.strptime(dt_str, '%Y%m%dT%H%M%S') + except ValueError: + try: + return datetime.strptime(dt_str, '%Y%m%d') + except ValueError: + return dt_str + +def format_dt(dt): + if isinstance(dt, datetime): + return dt.strftime('%Y-%m-%d %H:%M') + if isinstance(dt, date): + return dt.strftime('%Y-%m-%d') + return str(dt) + +def parse_ical_event(ical_text): + lines = ical_text.split('\n') + ev = {'summary': 'No title', 'start': None, 'end': None, 'description': '', 'uid': None, 'rrule': None} + key = None + val = '' + for line in lines: + stripped = line.strip() + if stripped.startswith(' ') or stripped.startswith('\t'): + if key: + val += stripped[1:] + continue + if key: + if key == 'SUMMARY': ev['summary'] = val + elif key == 'DESCRIPTION': ev['description'] = val + elif key == 'DTSTART': ev['start'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'DTEND': ev['end'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'UID': ev['uid'] = val + elif key == 'RRULE': ev['rrule'] = val + if ':' in stripped: + parts = stripped.split(':', 1) + key = parts[0].split(';')[0] + val = parts[1] if len(parts) > 1 else '' + if key: + if key == 'SUMMARY': ev['summary'] = val + elif key == 'DESCRIPTION': ev['description'] = val + elif key == 'DTSTART': ev['start'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'DTEND': ev['end'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'UID': ev['uid'] = val + elif key == 'RRULE': ev['rrule'] = val + return ev + +def query_events(calendar_url, start_dt, end_dt, search=None): + start_str = start_dt.strftime('%Y%m%dT%H%M%SZ') + end_str = end_dt.strftime('%Y%m%dT%H%M%SZ') + body = f'''<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="{start_str}" end="{end_str}"/> + </c:comp-filter> + </c:comp-filter> + </c:filter> +</c:calendar-query>''' + try: + resp = make_request(calendar_url, method='REPORT', body=body, depth='1') + except Exception: + return [] + root = ET.fromstring(resp) + events = [] + for r in root.findall(f'{NS_DAV}response'): + href = r.find(f'{NS_DAV}href') + propstat = r.find(f'{NS_DAV}propstat') + if href is None or propstat is None: + continue + prop = propstat.find(f'{NS_DAV}prop') + if prop is None: + continue + etag = prop.find(f'{NS_DAV}getetag') + caldata = prop.find(f'{NS_CALDAV}calendar-data') + if caldata is not None and caldata.text: + ev = parse_ical_event(caldata.text) + if ev and (not search or search.lower() in ev.get('summary','').lower() or search.lower() in ev.get('description','').lower()): + ev['etag'] = etag.text if etag is not None else None + ev['href'] = href.text + events.append(ev) + return events + +def ical_dump(ev): + ics = [] + ics.append('BEGIN:VCALENDAR') + ics.append('VERSION:2.0') + ics.append('PRODID:-//OpenClaw//Calendar//EN') + ics.append('BEGIN:VEVENT') + if ev.get('uid'): + ics.append(f"UID:{ev['uid']}") + if ev.get('rrule'): + ics.append(f"RRULE:{ev['rrule']}") + # DTSTART with TZID if original had one; simplified here + start = ev.get('start') + if isinstance(start, datetime): + ics.append(f"DTSTART:{start.strftime('%Y%m%dT%H%M%S')}") + elif isinstance(start, date): + ics.append(f"DTSTART:{start.strftime('%Y%m%d')}") + end = ev.get('end') + if isinstance(end, datetime): + ics.append(f"DTEND:{end.strftime('%Y%m%dT%H%M%S')}") + elif isinstance(end, date): + ics.append(f"DTEND:{end.strftime('%Y%m%d')}") + ics.append(f"SUMMARY:{ev.get('summary','')}") + if ev.get('description'): + ics.append(f"DESCRIPTION:{ev.get('description','')}") + ics.append('END:VEVENT') + ics.append('END:VCALENDAR') + return '\n'.join(ics) + +def cmd_list(args): + cals = get_calendars() + if not cals: + print("No calendars found.") + return + print("Calendars:") + for c in cals: + print(f"- {c['name']}") + +def cmd_events(args): + cal_url = get_calendar_url_by_name(args.calendar) + now = datetime.now() + if args.today: + start_dt = date(now.year, now.month, now.day) + end_dt = datetime.combine(start_dt + timedelta(days=1), datetime.min.time()) + elif args.date: + start_dt = date.fromisoformat(args.date) + end_dt = datetime.combine(start_dt + timedelta(days=1), datetime.min.time()) + elif args.start and args.end: + start_dt = datetime.fromisoformat(args.start) + end_dt = datetime.fromisoformat(args.end) + else: + # default: today + start_dt = date(now.year, now.month, now.day) + end_dt = datetime.combine(start_dt + timedelta(days=1), datetime.min.time()) + events = query_events(cal_url, start_dt, end_dt, search=args.search) + if not events: + print("No events found.") + return + # sort by start + events.sort(key=lambda e: e.get('start') or datetime.min) + out = [] + for ev in events: + start = ev.get('start') + time_str = format_dt(start) if start else 'All-day' + out.append(f"[{time_str}] {ev.get('summary','')}") + print("\n".join(out)) + +def cmd_add(args): + cal_url = get_calendar_url_by_name(args.calendar) + start_dt = datetime.fromisoformat(args.start) + end_dt = datetime.fromisoformat(args.end) if args.end else start_dt + timedelta(hours=1) + uid = str(uuid.uuid4()) + dtstamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + start_str = start_dt.strftime('%Y%m%dT%H%M%S') + end_str = end_dt.strftime('%Y%m%dT%H%M%S') + ics = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//OpenClaw//Calendar//EN", + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{dtstamp}", + f"SUMMARY:{args.summary}", + f"DTSTART:{start_str}", + f"DTEND:{end_str}" + ] + if args.recurrence: + ics.append(f"RRULE:{args.recurrence}") + if args.description: + ics.append(f"DESCRIPTION:{args.description}") + ics.extend(["END:VEVENT", "END:VCALENDAR"]) + event_url = f"{cal_url.rstrip('/')}/{uid}.ics" + try: + make_request(event_url, method='PUT', body='\n'.join(ics)) + print(f"Added event: {args.summary}") + except Exception as e: + print(f"Failed to add event: {e}", file=sys.stderr) + sys.exit(1) + +def cmd_update(args): + # Need to find the event. Use search or known UID/HREF. + cal_url = get_calendar_url_by_name(args.calendar) + # If UID provided directly, we need to locate the href via a query first + if args.uid: + # search by UID in recent range (expand a window) + now = datetime.now() + start = now - timedelta(days=365) + end = now + timedelta(days=365) + candidates = query_events(cal_url, start, end, search=None) + target = None + for ev in candidates: + if ev.get('uid') == args.uid: + target = ev + break + if not target: + print(f"Event with UID {args.uid} not found.", file=sys.stderr) + sys.exit(1) + href = target['href'] + etag = target['etag'] + # fetch full iCal (we already have it partially) + ical_data = make_request(f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href, method='GET') + ev_data = parse_ical_event(ical_data) + else: + # Must have summary + date to identify + if not args.summary or not args.date: + print("Need either --uid or (--summary and --date) to identify event.", file=sys.stderr) + sys.exit(1) + target_date = date.fromisoformat(args.date) + start_dt = datetime.combine(target_date, datetime.min.time()) + end_dt = datetime.combine(target_date + timedelta(days=1), datetime.min.time()) + candidates = query_events(cal_url, start_dt, end_dt, search=args.summary) + if not candidates: + print(f"Event not found on {args.date} with summary containing '{args.summary}'.", file=sys.stderr) + sys.exit(1) + if len(candidates) > 1: + print(f"Multiple matches; narrow search or use --uid.", file=sys.stderr) + sys.exit(1) + ev_data = candidates[0] + href = ev_data['href'] + etag = ev_data['etag'] + ical_url = f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href + ical_text = make_request(ical_url, method='GET') + ev_data = parse_ical_event(ical_text) + # Apply updates + if args.set_summary is not None: + ev_data['summary'] = args.set_summary + if args.set_start: + ev_data['start'] = datetime.fromisoformat(args.set_start) + if args.set_end: + ev_data['end'] = datetime.fromisoformat(args.set_end) + if args.set_recurrence is not None: + ev_data['rrule'] = args.set_recurrence if args.set_recurrence else None + # Rebuild iCal + new_ical = ical_dump(ev_data) + update_url = f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href + try: + make_request(update_url, method='PUT', body=new_ical, etag=etag) + print(f"Updated event: {ev_data.get('summary')}") + except Exception as e: + print(f"Failed to update: {e}", file=sys.stderr) + sys.exit(1) + +def cmd_delete(args): + cal_url = get_calendar_url_by_name(args.calendar) + if args.uid: + now = datetime.now() + window_start = now - timedelta(days=365) + window_end = now + timedelta(days=365) + candidates = query_events(cal_url, window_start, window_end) + target = None + for ev in candidates: + if ev.get('uid') == args.uid: + target = ev + break + if not target: + print(f"Event with UID {args.uid} not found.", file=sys.stderr) + sys.exit(1) + href = target['href'] + etag = target['etag'] + elif args.date and args.summary: + target_date = date.fromisoformat(args.date) + start_dt = datetime.combine(target_date, datetime.min.time()) + end_dt = datetime.combine(target_date + timedelta(days=1), datetime.min.time()) + candidates = query_events(cal_url, start_dt, end_dt, search=args.summary) + if not candidates: + print("Event not found.", file=sys.stderr) + sys.exit(1) + if len(candidates) > 1: + print("Multiple matches; use --uid to be specific.", file=sys.stderr) + sys.exit(1) + target = candidates[0] + href = target['href'] + etag = target['etag'] + else: + print("Need --uid or both --date and --summary.", file=sys.stderr) + sys.exit(1) + delete_url = f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href + try: + make_request(delete_url, method='DELETE', etag=etag) + print(f"Deleted event: {target.get('summary')}") + except Exception as e: + print(f"Failed to delete: {e}", file=sys.stderr) + sys.exit(1) + +def cmd_test(args): + ok = True + msg = [] + if not NEXTCLOUD_URL: + ok = False + msg.append("NEXTCLOUD_URL not set") + if not NEXTCLOUD_USER: + ok = False + msg.append("NEXTCLOUD_USER not set") + if not NEXTCLOUD_PASSWORD: + ok = False + msg.append("NEXTCLOUD_PASSWORD not set") + if not CALDAV_PRINCIPAL: + ok = False + msg.append("CALDAV_PRINCIPAL not set") + if not ok: + print("Missing config:\n " + "\n ".join(msg)) + sys.exit(1) + try: + get_calendar_home() + print("Connection successful.") + except Exception as e: + print(f"Connection failed: {e}") + sys.exit(1) + +def main(): + parser = argparse.ArgumentParser(description='Unified Nextcloud CalDAV CLI') + sub = parser.add_subparsers(dest='cmd', required=True) + + sub_list = sub.add_parser('list', help='List calendars') + sub_list.set_defaults(func=cmd_list) + + sub_events = sub.add_parser('events', help='View events') + sub_events.add_argument('--calendar', help='Calendar name (default: first)') + grp = sub_events.add_mutually_exclusive_group() + grp.add_argument('--today', action='store_true') + grp.add_argument('--date', help='Specific date YYYY-MM-DD') + grp.add_argument('--start', '--begin', help='Start datetime ISO') + sub_events.add_argument('--end', help='End datetime ISO (with --start)') + sub_events.add_argument('--search', help='Text search in summary/description') + sub_events.set_defaults(func=cmd_events) + + sub_add = sub.add_parser('add', help='Add event') + sub_add.add_argument('--calendar', help='Calendar name') + sub_add.add_argument('--summary', required=True, help='Event title') + sub_add.add_argument('--start', required=True, help='Start datetime ISO (YYYY-MM-DD HH:MM:SS or ISO format)') + sub_add.add_argument('--end', help='End datetime ISO (default: start + 1h)') + sub_add.add_argument('--recurrence', help='RRULE string (e.g., FREQ=WEEKLY;BYDAY=MO)') + sub_add.add_argument('--description', help='Event description') + sub_add.set_defaults(func=cmd_add) + + sub_update = sub.add_parser('update', help='Update existing event') + sub_update.add_argument('--calendar', help='Calendar name') + idgrp = sub_update.add_mutually_exclusive_group(required=True) + idgrp.add_argument('--uid', help='Event UID to update') + idgrp.add_argument('--summary', help='Event title (partial) match') + sub_update.add_argument('--date', help='Date of event (required with --summary)') + sub_update.add_argument('--set-summary', help='New summary') + sub_update.add_argument('--set-start', help='New start datetime ISO') + sub_update.add_argument('--set-end', help='New end datetime ISO') + sub_update.add_argument('--set-recurrence', help='New RRULE (or empty to remove)') + sub_update.set_defaults(func=cmd_update) + + sub_delete = sub.add_parser('delete', help='Delete event') + sub_delete.add_argument('--calendar', help='Calendar name') + delgrp = sub_delete.add_mutually_exclusive_group(required=True) + delgrp.add_argument('--uid', help='Event UID to delete') + delgrp.add_argument('--summary', help='Event title match') + sub_delete.add_argument('--date', help='Date of event (required with --summary)') + sub_delete.set_defaults(func=cmd_delete) + + sub_test = sub.add_parser('test', help='Test connection and config') + sub_test.set_defaults(func=cmd_test) + + args = parser.parse_args() + try: + args.func(args) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/openclaw/workspace/nextcloud-calendar/.env.example b/openclaw/workspace/nextcloud-calendar/.env.example new file mode 100644 index 0000000..6c5410f --- /dev/null +++ b/openclaw/workspace/nextcloud-calendar/.env.example @@ -0,0 +1,7 @@ +# Nextcloud Calendar configuration +# Copy this file to .env and fill in your values. + +NEXTCLOUD_URL=https://tower.scarif.space +NEXTCLOUD_USER=chris +NEXTCLOUD_PASSWORD=your_app_password_here +CALDAV_PRINCIPAL=/remote.php/dav/principals/users/chris/ diff --git a/openclaw/workspace/nextcloud-calendar/SKILL.md b/openclaw/workspace/nextcloud-calendar/SKILL.md new file mode 100644 index 0000000..0c730dc --- /dev/null +++ b/openclaw/workspace/nextcloud-calendar/SKILL.md @@ -0,0 +1,80 @@ +--- +name: nextcloud-calendar +description: Manage Nextcloud calendars via CalDAV. Provides a unified CLI for listing, searching, adding, updating, and deleting events. +--- + +# Nextcloud Calendar Skill + +All-in-one CalDAV management for Nextcloud. Uses environment variables for configuration. + +## Prerequisites + +Set these environment variables for your Nextcloud instance: + +- `NEXTCLOUD_URL` — Base URL (e.g., `https://tower.scarif.space`) +- `NEXTCLOUD_USER` — Username (e.g., `chris`) +- `NEXTCLOUD_PASSWORD` — App password (Settings → Security → Devices & Sessions) +- `CALDAV_PRINCIPAL` — Principal path (e.g., `/remote.php/dav/principals/users/chris/`) + +You can copy `.env.example` to `.env` and fill it out if using a local runner that loads dotenv. + +## Unified CLI + +All operations go through `scripts/ncal.py`: + +```bash +cd /home/node/.openclaw/workspace/nextcloud-calendar/scripts +python3 ncal.py <command> [options] +``` + +### Commands + +| Command | Description | Important Options | +|---------|-------------|-------------------| +| `list` | List available calendars | none | +| `events` | View events (defaults to **Personal** calendar) | `--today`, `--date YYYY-MM-DD`, `--start`/`--end`, `--search <text>`, `--calendar <name>` | +| `add` | Create event | `--summary`, `--start`, `--end`, `--recurrence`, `--description` | +| `update` | Modify event | `--uid` OR `--summary` + `--date`, plus `--set-*` flags | +| `delete` | Remove event | `--uid` OR `--summary` + `--date` | +| `exception` | Create exception for recurring event | `--uid`, `--date` (instance date), `--start` (new time), `--end` (new time) | +| `test` | Check config and connectivity | none | + +### Examples + +```bash +# List calendars +python3 ncal.py list + +# Today's events +python3 ncal.py events --today + +# Events on a specific date +python3 ncal.py events --date 2026-02-13 + +# Search for events containing "tennis" +python3 ncal.py events --search tennis + +# Add an event +python3 ncal.py add --summary "Dentist" --start "2026-02-14 14:00:00" --end "2026-02-14 15:00:00" + +# Add recurring weekly event (Wednesdays 18:30-19:30) +python3 ncal.py add --summary "Tennis Coaching" --start "2026-02-11 18:30:00" --end "2026-02-11 19:30:00" --recurrence "FREQ=WEEKLY;BYDAY=WE" + +# Update an event by UID +python3 ncal.py update --uid <uid> --set-summary "New Title" + +# Delete by UID +python3 ncal.py delete --uid <uid> + +# Create exception for recurring event (change Feb 18th instance to 13:00-14:00) +python3 ncal.py exception --uid <uid> --date 2026-02-18 --start 13:00 --end 14:00 +``` + +## Notes + +- **Default calendar**: If you don't specify `--calendar`, the command defaults to your **Personal** calendar. +- **Timestamps**: Recurring events may show their original creation date rather than each occurrence—this is normal CalDAV behavior and doesn't affect functionality. +- Times are local (no timezone conversion performed). Use consistent times in your calendar's timezone. +- Recurrence rules follow iCalendar RRULE format. +- The script does not use any third-party Python packages beyond the standard library. +- For security, avoid hardcoding passwords; use environment variables or a `.env` file loaded by your shell. diff --git a/openclaw/workspace/nextcloud-calendar/scripts/__pycache__/ncal.cpython-311.pyc b/openclaw/workspace/nextcloud-calendar/scripts/__pycache__/ncal.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff1d5faec5dd1f5d59165684ad4f882e14183a88 GIT binary patch literal 31361 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 literal 0 HcmV?d00001 diff --git a/openclaw/workspace/nextcloud-calendar/scripts/ncal.py b/openclaw/workspace/nextcloud-calendar/scripts/ncal.py new file mode 100644 index 0000000..2d4dabd --- /dev/null +++ b/openclaw/workspace/nextcloud-calendar/scripts/ncal.py @@ -0,0 +1,622 @@ +#!/usr/bin/env python3 +""" +Unified Nextcloud CalDAV CLI. + +Commands: + list - List calendars + events - View events (supports --today, --date, --start/--end, --search) + add - Create event (--summary, --start, --end, --recurrence, --description) + update - Modify event (--uid OR --summary+--date, with --set-* options) + delete - Remove event (--uid OR --summary+--date) + exception - Create exception for recurring event instance (--uid, --date, --start, --end) + test - Verify connection + +Requires environment variables: + NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD, CALDAV_PRINCIPAL +""" + +import os, sys, argparse, urllib.request, urllib.error, uuid, json +from datetime import datetime, date, timedelta, timezone +import xml.etree.ElementTree as ET + +# Config from environment +NEXTCLOUD_URL = os.getenv('NEXTCLOUD_URL', '').rstrip('/') +NEXTCLOUD_USER = os.getenv('NEXTCLOUD_USER', '') +NEXTCLOUD_PASSWORD = os.getenv('NEXTCLOUD_PASSWORD', '') +CALDAV_PRINCIPAL = os.getenv('CALDAV_PRINCIPAL', '') + +NS_DAV = '{DAV:}' +NS_CALDAV = '{urn:ietf:params:xml:ns:caldav}' + +def make_request(url, method='PROPFIND', body=None, depth='1', etag=None): + pw = urllib.request.HTTPPasswordMgrWithDefaultRealm() + pw.add_password(None, NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD) + opener = urllib.request.build_opener(urllib.request.HTTPBasicAuthHandler(pw)) + req = urllib.request.Request(url, data=body.encode() if body else None, method=method) + req.add_header('Content-Type', 'text/xml; charset=utf-8') + if depth: req.add_header('Depth', depth) + if etag: req.add_header('If-Match', etag) + req.add_header('User-Agent', 'OpenClaw-Calendar/1.0') + return opener.open(req).read().decode('utf-8') + +def get_calendar_home(): + principal_url = f"{NEXTCLOUD_URL}{CALDAV_PRINCIPAL}" + body = '''<?xml version="1.0"?> +<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> + <d:prop><c:calendar-home-set/></d:prop> +</d:propfind>''' + resp = make_request(principal_url, body=body, depth='0') + root = ET.fromstring(resp) + elem = root.find(f'.//{NS_CALDAV}calendar-home-set/{NS_DAV}href') + if elem is not None: + href = elem.text.strip() + return href if href.startswith('http') else f"{NEXTCLOUD_URL}{href}" + return f"{NEXTCLOUD_URL}/remote.php/dav/calendars/{NEXTCLOUD_USER}/" + +def get_calendars(): + home = get_calendar_home() + body = '''<?xml version="1.0"?> +<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> + <d:prop><d:displayname/><c:supported-calendar-component-set/></d:prop> +</d:propfind>''' + resp = make_request(home, body=body, depth='1') + root = ET.fromstring(resp) + cals = [] + for r in root.findall(f'{NS_DAV}response'): + href_el = r.find(f'{NS_DAV}href') + if href_el is None or not href_el.text.endswith('/'): + continue + prop = r.find(f'{NS_DAV}propstat/{NS_DAV}prop') + if prop is None: + continue + name_el = prop.find(f'{NS_DAV}displayname') + name = name_el.text if name_el is not None else href_el.text.strip('/').split('/')[-1] + cals.append({ + 'name': name, + 'href': href_el.text, + 'url': f"{NEXTCLOUD_URL}{href_el.text}" if href_el.text.startswith('/') else href_el.text + }) + return cals + +def get_calendar_url_by_name(name=None): + cals = get_calendars() + if not cals: + raise Exception("No calendars found") + if name: + for cal in cals: + if cal['name'] == name: + return cal['url'] + raise Exception(f"Calendar '{name}' not found") + # Default to Personal calendar, fallback to first available + for cal in cals: + if cal['name'] == 'Personal': + return cal['url'] + return cals[0]['url'] + +def parse_datetime_ical(dt_str): + dt_str = dt_str.strip() + if dt_str.endswith('Z'): + try: + return datetime.strptime(dt_str, '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc) + except ValueError: + pass + try: + return datetime.strptime(dt_str, '%Y%m%dT%H%M%S') + except ValueError: + try: + return datetime.strptime(dt_str, '%Y%m%d') + except ValueError: + return dt_str + +def format_dt(dt): + if isinstance(dt, datetime): + return dt.strftime('%Y-%m-%d %H:%M') + if isinstance(dt, date): + return dt.strftime('%Y-%m-%d') + return str(dt) + +def parse_ical_event(ical_text): + lines = ical_text.split('\n') + ev = {'summary': 'No title', 'start': None, 'end': None, 'description': '', 'uid': None, 'rrule': None} + key = None + val = '' + for line in lines: + stripped = line.strip() + if stripped.startswith((' ', '\t')): + if key: + val += stripped[1:] + continue + if key: + if key == 'SUMMARY': ev['summary'] = val + elif key == 'DESCRIPTION': ev['description'] = val + elif key == 'DTSTART': ev['start'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'DTEND': ev['end'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'UID': ev['uid'] = val + elif key == 'RRULE': ev['rrule'] = val + if ':' in stripped: + parts = stripped.split(':', 1) + key = parts[0].split(';')[0] + val = parts[1] if len(parts) > 1 else '' + if key: + if key == 'SUMMARY': ev['summary'] = val + elif key == 'DESCRIPTION': ev['description'] = val + elif key == 'DTSTART': ev['start'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'DTEND': ev['end'] = parse_datetime_ical(val.split(';')[1] if ';' in val else val) + elif key == 'UID': ev['uid'] = val + elif key == 'RRULE': ev['rrule'] = val + return ev + + +def unfold_ical_lines(ical_text): + lines = ical_text.splitlines() + unfolded = [] + for line in lines: + if line.startswith((' ', '\t')) and unfolded: + unfolded[-1] += line[1:] + else: + unfolded.append(line) + return unfolded + + +def parse_prop_line(line): + if ':' not in line: + return None, {}, None + name_params, value = line.split(':', 1) + parts = name_params.split(';') + name = parts[0].strip().upper() + params = {} + for p in parts[1:]: + if '=' in p: + k, v = p.split('=', 1) + params[k.upper()] = v + return name, params, value + + +def extract_master_event(ical_text, uid): + lines = unfold_ical_lines(ical_text) + events = [] + current = None + for line in lines: + if line.strip() == 'BEGIN:VEVENT': + current = [] + elif line.strip() == 'END:VEVENT': + if current is not None: + events.append(current) + current = None + elif current is not None: + current.append(line) + for ev_lines in events: + props = {} + has_recurrence_id = False + ev_uid = None + for line in ev_lines: + name, params, value = parse_prop_line(line) + if not name: + continue + if name == 'RECURRENCE-ID': + has_recurrence_id = True + if name == 'UID': + ev_uid = value + props[name] = (value, params) + if ev_uid == uid and not has_recurrence_id: + return props + return None + + +def insert_exception(ical_text, exception_lines): + insert_text = '\n'.join(exception_lines) + if 'END:VCALENDAR' not in ical_text: + raise Exception('Invalid VCALENDAR data') + head, tail = ical_text.rsplit('END:VCALENDAR', 1) + head = head.rstrip('\n') + new_text = head + '\n' + insert_text + '\nEND:VCALENDAR' + tail + return new_text + + +def format_ical_date(dt_obj): + return dt_obj.strftime('%Y%m%dT%H%M%S') + +def query_events(calendar_url, start_dt, end_dt, search=None): + start_str = start_dt.strftime('%Y%m%dT%H%M%SZ') + end_str = end_dt.strftime('%Y%m%dT%H%M%SZ') + body = f'''<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="{start_str}" end="{end_str}"/> + </c:comp-filter> + </c:comp-filter> + </c:filter> +</c:calendar-query>''' + try: + resp = make_request(calendar_url, method='REPORT', body=body, depth='1') + except Exception: + return [] + root = ET.fromstring(resp) + events = [] + for r in root.findall(f'{NS_DAV}response'): + href = r.find(f'{NS_DAV}href') + propstat = r.find(f'{NS_DAV}propstat') + if href is None or propstat is None: + continue + prop = propstat.find(f'{NS_DAV}prop') + if prop is None: + continue + etag = prop.find(f'{NS_DAV}getetag') + caldata = prop.find(f'{NS_CALDAV}calendar-data') + if caldata is not None and caldata.text: + ev = parse_ical_event(caldata.text) + if ev and (not search or search.lower() in ev.get('summary','').lower() or search.lower() in ev.get('description','').lower()): + ev['etag'] = etag.text if etag is not None else None + ev['href'] = href.text + events.append(ev) + return events + + +def ical_dump(ev): + ics = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//OpenClaw//Calendar//EN', + 'BEGIN:VEVENT' + ] + if ev.get('uid'): + ics.append(f"UID:{ev['uid']}") + if ev.get('rrule'): + ics.append(f"RRULE:{ev['rrule']}") + start = ev.get('start') + if isinstance(start, datetime): + ics.append(f"DTSTART:{start.strftime('%Y%m%dT%H%M%S')}") + elif isinstance(start, date): + ics.append(f"DTSTART:{start.strftime('%Y%m%d')}") + end = ev.get('end') + if isinstance(end, datetime): + ics.append(f"DTEND:{end.strftime('%Y%m%dT%H%M%S')}") + elif isinstance(end, date): + ics.append(f"DTEND:{end.strftime('%Y%m%d')}") + ics.append(f"SUMMARY:{ev.get('summary','')}") + if ev.get('description'): + ics.append(f"DESCRIPTION:{ev.get('description','')}") + ics.extend(['END:VEVENT', 'END:VCALENDAR']) + return '\n'.join(ics) + + +def cmd_list(args): + cals = get_calendars() + if not cals: + print("No calendars found.") + return + print("Calendars:") + for c in cals: + print(f"- {c['name']}") + + +def cmd_events(args): + cal_url = get_calendar_url_by_name(args.calendar) + now = datetime.now() + if args.today: + start_dt = date(now.year, now.month, now.day) + end_dt = datetime.combine(start_dt + timedelta(days=1), datetime.min.time()) + elif args.date: + start_dt = date.fromisoformat(args.date) + end_dt = datetime.combine(start_dt + timedelta(days=1), datetime.min.time()) + elif args.start and args.end: + start_dt = datetime.fromisoformat(args.start) + end_dt = datetime.fromisoformat(args.end) + else: + start_dt = date(now.year, now.month, now.day) + end_dt = datetime.combine(start_dt + timedelta(days=1), datetime.min.time()) + events = query_events(cal_url, start_dt, end_dt, search=args.search) + if not events: + print("No events found.") + return + events.sort(key=lambda e: e.get('start') or datetime.min) + for ev in events: + start = ev.get('start') + time_str = format_dt(start) if start else 'All-day' + print(f"[{time_str}] {ev.get('summary','')}") + + +def cmd_add(args): + cal_url = get_calendar_url_by_name(args.calendar) + start_dt = datetime.fromisoformat(args.start) + end_dt = datetime.fromisoformat(args.end) if args.end else start_dt + timedelta(hours=1) + uid = str(uuid.uuid4()) + dtstamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + start_str = start_dt.strftime('%Y%m%dT%H%M%S') + end_str = end_dt.strftime('%Y%m%dT%H%M%S') + ics = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//OpenClaw//Calendar//EN', + 'BEGIN:VEVENT', + f"UID:{uid}", + f"DTSTAMP:{dtstamp}", + f"SUMMARY:{args.summary}", + f"DTSTART:{start_str}", + f"DTEND:{end_str}" + ] + if args.recurrence: + ics.append(f"RRULE:{args.recurrence}") + if args.description: + ics.append(f"DESCRIPTION:{args.description}") + ics.extend(['END:VEVENT', 'END:VCALENDAR']) + event_url = f"{cal_url.rstrip('/')}/{uid}.ics" + try: + make_request(event_url, method='PUT', body='\n'.join(ics)) + print(f"Added event: {args.summary}") + except Exception as e: + print(f"Failed to add event: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_update(args): + cal_url = get_calendar_url_by_name(args.calendar) + if args.uid: + now = datetime.now() + start = now - timedelta(days=365) + end = now + timedelta(days=365) + candidates = query_events(cal_url, start, end) + target = None + for ev in candidates: + if ev.get('uid') == args.uid: + target = ev + break + if not target: + print(f"Event with UID {args.uid} not found.", file=sys.stderr) + sys.exit(1) + href = target['href'] + etag = target['etag'] + ical_text = make_request(f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href, method='GET') + ev_data = parse_ical_event(ical_text) + else: + if not args.summary or not args.date: + print("Need either --uid or (--summary and --date) to identify event.", file=sys.stderr) + sys.exit(1) + target_date = date.fromisoformat(args.date) + start_dt = datetime.combine(target_date, datetime.min.time()) + end_dt = datetime.combine(target_date + timedelta(days=1), datetime.min.time()) + candidates = query_events(cal_url, start_dt, end_dt, search=args.summary) + if not candidates: + print(f"Event not found on {args.date} with summary containing '{args.summary}'.", file=sys.stderr) + sys.exit(1) + if len(candidates) > 1: + print(f"Multiple matches; narrow search or use --uid.", file=sys.stderr) + sys.exit(1) + ev_data = candidates[0] + href = ev_data['href'] + etag = ev_data['etag'] + ical_url = f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href + ical_text = make_request(ical_url, method='GET') + ev_data = parse_ical_event(ical_text) + if args.set_summary is not None: + ev_data['summary'] = args.set_summary + if args.set_start: + ev_data['start'] = datetime.fromisoformat(args.set_start) + if args.set_end: + ev_data['end'] = datetime.fromisoformat(args.set_end) + if args.set_recurrence is not None: + ev_data['rrule'] = args.set_recurrence if args.set_recurrence else None + new_ical = ical_dump(ev_data) + update_url = f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href + try: + make_request(update_url, method='PUT', body=new_ical, etag=etag) + print(f"Updated event: {ev_data.get('summary')}") + except Exception as e: + print(f"Failed to update: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_delete(args): + cal_url = get_calendar_url_by_name(args.calendar) + if args.uid: + now = datetime.now() + start = now - timedelta(days=365) + end = now + timedelta(days=365) + candidates = query_events(cal_url, start, end) + target = None + for ev in candidates: + if ev.get('uid') == args.uid: + target = ev + break + if not target: + print(f"Event with UID {args.uid} not found.", file=sys.stderr) + sys.exit(1) + href = target['href'] + etag = target['etag'] + elif args.date and args.summary: + target_date = date.fromisoformat(args.date) + start_dt = datetime.combine(target_date, datetime.min.time()) + end_dt = datetime.combine(target_date + timedelta(days=1), datetime.min.time()) + candidates = query_events(cal_url, start_dt, end_dt, search=args.summary) + if not candidates: + print("Event not found.", file=sys.stderr) + sys.exit(1) + if len(candidates) > 1: + print("Multiple matches; use --uid.", file=sys.stderr) + sys.exit(1) + target = candidates[0] + href = target['href'] + etag = target['etag'] + else: + print("Need --uid or both --date and --summary.", file=sys.stderr) + sys.exit(1) + delete_url = f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href + try: + make_request(delete_url, method='DELETE', etag=etag) + print(f"Deleted event: {target.get('summary')}") + except Exception as e: + print(f"Failed to delete: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_exception(args): + cal_url = get_calendar_url_by_name(args.calendar) + now = datetime.now() + start = now - timedelta(days=365) + end = now + timedelta(days=365*5) + candidates = query_events(cal_url, start, end) + target = None + for ev in candidates: + if ev.get('uid') == args.uid: + target = ev + break + if not target: + print(f"Event with UID {args.uid} not found.", file=sys.stderr) + sys.exit(1) + href = target['href'] + etag = target['etag'] + ical_url = f"{NEXTCLOUD_URL}{href}" if href.startswith('/') else href + ical_text = make_request(ical_url, method='GET') + master = extract_master_event(ical_text, args.uid) + if not master: + print(f"Master event for UID {args.uid} not found.", file=sys.stderr) + sys.exit(1) + + master_dtstart, dtstart_params = master.get('DTSTART', (None, {})) + master_dtend, dtend_params = master.get('DTEND', (None, {})) + master_summary = master.get('SUMMARY', ('', {}))[0] + master_description = master.get('DESCRIPTION', ('', {}))[0] + master_sequence = master.get('SEQUENCE', ('0', {}))[0] or '0' + + if not master_dtstart: + print("Master event missing DTSTART.", file=sys.stderr) + sys.exit(1) + + tzid = dtstart_params.get('TZID') + # Recurrence-id uses original instance datetime (same time-of-day as master) + if master_dtstart.endswith('Z'): + recurrence_id = f"{args.date.replace('-', '')}T{master_dtstart[9:15]}Z" + else: + time_part = master_dtstart[9:15] if 'T' in master_dtstart else '000000' + recurrence_id = f"{args.date.replace('-', '')}T{time_part}" + + start_dt = datetime.fromisoformat(f"{args.date} {args.start}") + end_dt = datetime.fromisoformat(f"{args.date} {args.end}") + start_str = format_ical_date(start_dt) + end_str = format_ical_date(end_dt) + + seq = 0 + try: + seq = int(master_sequence) + except ValueError: + seq = 0 + seq += 1 + + dtstamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + + exception_lines = [ + 'BEGIN:VEVENT', + f"UID:{args.uid}", + f"DTSTAMP:{dtstamp}", + f"SEQUENCE:{seq}", + ] + + if tzid: + exception_lines.append(f"RECURRENCE-ID;TZID={tzid}:{recurrence_id}") + exception_lines.append(f"DTSTART;TZID={tzid}:{start_str}") + exception_lines.append(f"DTEND;TZID={tzid}:{end_str}") + else: + exception_lines.append(f"RECURRENCE-ID:{recurrence_id}") + exception_lines.append(f"DTSTART:{start_str}") + exception_lines.append(f"DTEND:{end_str}") + + if master_summary: + exception_lines.append(f"SUMMARY:{master_summary}") + if master_description: + exception_lines.append(f"DESCRIPTION:{master_description}") + exception_lines.append('END:VEVENT') + + new_ical = insert_exception(ical_text, exception_lines) + try: + make_request(ical_url, method='PUT', body=new_ical, etag=etag) + print(f"Created exception for UID {args.uid} on {args.date}") + except Exception as e: + print(f"Failed to create exception: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_test(args): + missing = [] + if not NEXTCLOUD_URL: missing.append("NEXTCLOUD_URL") + if not NEXTCLOUD_USER: missing.append("NEXTCLOUD_USER") + if not NEXTCLOUD_PASSWORD: missing.append("NEXTCLOUD_PASSWORD") + if not CALDAV_PRINCIPAL: missing.append("CALDAV_PRINCIPAL") + if missing: + print("Missing environment variables: " + ", ".join(missing)) + sys.exit(1) + try: + get_calendar_home() + print("Connection successful. Calendars available:") + for cal in get_calendars(): + print(f"- {cal['name']}") + except Exception as e: + print(f"Connection failed: {e}") + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description='Unified Nextcloud CalDAV CLI') + sub = parser.add_subparsers(dest='cmd', required=True) + + sub.add_parser('list', help='List calendars').set_defaults(func=cmd_list) + + ev = sub.add_parser('events', help='View events') + ev.add_argument('--calendar', help='Calendar name (default: first)') + grp = ev.add_mutually_exclusive_group() + grp.add_argument('--today', action='store_true') + grp.add_argument('--date', help='Specific date YYYY-MM-DD') + grp.add_argument('--start', help='Start datetime ISO') + ev.add_argument('--end', help='End datetime ISO (with --start)') + ev.add_argument('--search', help='Text search in summary/description') + ev.set_defaults(func=cmd_events) + + add = sub.add_parser('add', help='Add event') + add.add_argument('--calendar', help='Calendar name') + add.add_argument('--summary', required=True, help='Event title') + add.add_argument('--start', required=True, help='Start datetime ISO (YYYY-MM-DD HH:MM:SS)') + add.add_argument('--end', help='End datetime ISO (default: start + 1h)') + add.add_argument('--recurrence', help='RRULE string (e.g., FREQ=WEEKLY;BYDAY=MO)') + add.add_argument('--description', help='Event description') + add.set_defaults(func=cmd_add) + + upd = sub.add_parser('update', help='Update existing event') + upd.add_argument('--calendar', help='Calendar name') + idgrp = upd.add_mutually_exclusive_group(required=True) + idgrp.add_argument('--uid', help='Event UID to update') + idgrp.add_argument('--summary', help='Event title (partial) match') + upd.add_argument('--date', help='Date of event (required with --summary)') + upd.add_argument('--set-summary', help='New summary') + upd.add_argument('--set-start', help='New start datetime ISO') + upd.add_argument('--set-end', help='New end datetime ISO') + upd.add_argument('--set-recurrence', help='New RRULE (or empty to remove)') + upd.set_defaults(func=cmd_update) + + delete = sub.add_parser('delete', help='Delete event') + delete.add_argument('--calendar', help='Calendar name') + delgrp = delete.add_mutually_exclusive_group(required=True) + delgrp.add_argument('--uid', help='Event UID to delete') + delgrp.add_argument('--summary', help='Event title match') + delete.add_argument('--date', help='Date of event (required with --summary)') + delete.set_defaults(func=cmd_delete) + + exc = sub.add_parser('exception', help='Create exception for recurring event instance') + exc.add_argument('--calendar', help='Calendar name') + exc.add_argument('--uid', required=True, help='Event UID') + exc.add_argument('--date', required=True, help='Date of instance to override (YYYY-MM-DD)') + exc.add_argument('--start', required=True, help='New start time (HH:MM)') + exc.add_argument('--end', required=True, help='New end time (HH:MM)') + exc.set_defaults(func=cmd_exception) + + sub.add_parser('test', help='Test connection and config').set_defaults(func=cmd_test) + + args = parser.parse_args() + try: + args.func(args) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main()