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 0000000..4edf95a Binary files /dev/null and b/openclaw/workspace/_trash/nextcloud-calendar/scripts/__pycache__/calendar_sync.cpython-311.pyc differ 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 0000000..ff1d5fa Binary files /dev/null and b/openclaw/workspace/nextcloud-calendar/scripts/__pycache__/ncal.cpython-311.pyc differ 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()