First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
---
name: ics2json
description: Download an iCalendar (ICS) feed from a URL and output structured JSON with all events. Use this skill whenever the user needs to read, analyze, or integrate events from a public iCal feed such as Teamup or Google Calendar, especially when no API keys are available. Also use as the base for cron jobs that periodically analyze a calendar feed. If the user mentions an .ics file or calendar feed, use this skill.
---
# ics2json
_Updated: 2026-06-08_
Downloads an iCalendar (ICS) feed from a URL and outputs structured JSON with all events.
## When to use
- Reading and analyzing events from a public iCal feed (Teamup, Google Calendar, etc.)
- Integrating an external calendar without API keys
- As the base for cron jobs that periodically analyze a feed
## Script
`python3 skills/ics2json/ics2json.py <url> [options]`
### Options
| Flag | Description |
|------|-------------|
| `--days N` | Only events starting within the next N days |
| `--all` | Include past events (default: future or ongoing only) |
| `--pretty` | Pretty-print JSON output (default: compact, single-line) |
| `--meta` | Only output calendar metadata (name, description, event count). No events returned. |
### Output JSON format
```json
{
"calendar": "Calendar name",
"description": "Calendar description",
"feed_url": "Feed URL",
"fetched_at": "2026-05-20T12:00:00+01:00",
"total_events": 5,
"events": [
{
"uid": "TU123456",
"title": "Event title",
"description": "Description...",
"location": "Venue",
"where": "Teamup address",
"categories": "kink oriented event ⛓️",
"event_url": "https://teamup.com/...",
"external_url": "https://tickets.example.com",
"start": "2026-05-20T19:00:00+01:00",
"end": "2026-05-20T23:00:00+01:00",
"created": "2026-05-01T10:00:00+00:00",
"last_modified": null,
"stamp": "2026-05-19T13:18:47+00:00",
"attachments": [{"url": "https://...", "type": "image/jpeg", "filename": "flyer.jpg"}]
}
]
}
```
### Examples
```bash
# Download a Teamup feed, future events only (compact output)
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics
# Metadata only (compact)
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics --meta
# Only the next 30 days (compact)
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics --days 30
# All events (including past), pretty-printed for human reading
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics --all --pretty
```
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Download an iCal feed and output events as JSON."""
import argparse
import json
import sys
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from urllib.request import urlopen
from icalendar import Calendar
def parse_dt(value: Any) -> Optional[str]:
"""Parse an iCal date/datetime value and return ISO 8601 string."""
if value is None:
return None
dt = value.dt
if isinstance(dt, datetime):
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
# date-only (all-day events)
return dt.isoformat()
def extract_attachments(event: Any) -> List[Dict[str, str]]:
"""Extract ATTACH properties as a list of {url, type} dicts."""
attachments: List[Dict[str, str]] = []
if "ATTACH" in event:
# May be a single value or a list
items = event["ATTACH"] if isinstance(event["ATTACH"], list) else [event["ATTACH"]]
for item in items:
attach: Dict[str, str] = {"url": str(item)}
params = item.params
if "FMTTYPE" in params:
attach["type"] = params["FMTTYPE"]
if "FILENAME" in params:
attach["filename"] = params["FILENAME"]
attachments.append(attach)
return attachments
def extract_text(component: Any, key: str) -> Optional[str]:
"""Extract a text property, decoded from any encoding."""
raw = component.get(key)
if raw is None:
return None
# vCategory objects have a .cats attribute (list of vText)
if hasattr(raw, "cats"):
return ", ".join(str(item) for item in raw.cats)
# Other list-like properties
if isinstance(raw, list):
return ", ".join(str(item) for item in raw)
return str(raw)
def event_to_dict(event: Any) -> Dict[str, Any]:
"""Convert an iCal VEVENT to a flat dict."""
return {
"uid": extract_text(event, "UID"),
"title": extract_text(event, "SUMMARY"),
"description": extract_text(event, "DESCRIPTION"),
"location": extract_text(event, "LOCATION"),
"where": extract_text(event, "X-TEAMUP-WHERE"),
"categories": extract_text(event, "CATEGORIES"),
"event_url": extract_text(event, "URL"),
"external_url": extract_text(event, "X-TEAMUP-EVENT-URL"),
"start": parse_dt(event.get("DTSTART")),
"end": parse_dt(event.get("DTEND")),
"created": parse_dt(event.get("CREATED")),
"last_modified": parse_dt(event.get("LAST-MODIFIED")),
"stamp": parse_dt(event.get("DTSTAMP")),
"attachments": extract_attachments(event),
}
def download_feed(url: str) -> Calendar:
"""Download and parse an iCal feed."""
with urlopen(url) as response:
raw = response.read()
return Calendar.from_ical(raw)
def _parse_iso(iso: str) -> datetime:
"""Parse an ISO 8601 string, making it UTC-aware."""
dt = datetime.fromisoformat(iso)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def is_future(event_dict: Dict[str, Any], now: datetime) -> bool:
"""Check if an event is in the future (or ongoing)."""
end = event_dict.get("end")
if end is None:
start = event_dict.get("start")
if start is None:
return True
return _parse_iso(start) >= now
return _parse_iso(end) >= now
def is_within_days(event_dict: Dict[str, Any], days: int, now: datetime) -> bool:
"""Check if an event starts within the next N days."""
start = event_dict.get("start")
if start is None:
return True
dt = _parse_iso(start)
cutoff = now + timedelta(days=days)
return dt >= now and dt <= cutoff
def main() -> None:
parser = argparse.ArgumentParser(description="Download an iCal feed and output events as JSON")
parser.add_argument("url", help="URL of the iCal feed (.ics)")
parser.add_argument("--days", type=int, default=None, help="Only return events starting within the next N days")
parser.add_argument("--all", action="store_true", help="Include past events (default: future only)")
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output (default: compact)")
parser.add_argument("--meta", action="store_true", help="Only output calendar metadata (no events)")
args = parser.parse_args()
try:
cal = download_feed(args.url)
except Exception as e:
print(f"Error downloading feed: {e}", file=sys.stderr)
sys.exit(1)
now = datetime.now(timezone.utc)
# Calendar-level metadata
cal_name = extract_text(cal, "X-WR-CALNAME") or extract_text(cal, "SUMMARY") or "Unknown"
cal_desc = extract_text(cal, "X-WR-CALDESC") or extract_text(cal, "DESCRIPTION") or ""
# Count total events (all of them, unfiltered)
total_all = sum(1 for c in cal.walk() if c.name == "VEVENT")
output: Dict[str, Any] = {
"calendar": cal_name,
"description": cal_desc,
"feed_url": args.url,
"fetched_at": now.isoformat(),
"total_events": total_all,
}
if args.meta:
if args.pretty:
print(json.dumps(output, indent=2, ensure_ascii=False))
else:
print(json.dumps(output, ensure_ascii=False))
return
events: List[Dict[str, Any]] = []
for component in cal.walk():
if component.name != "VEVENT":
continue
ev = event_to_dict(component)
# Apply filters
if not args.all and not is_future(ev, now):
continue
if args.days is not None and not is_within_days(ev, args.days, now):
continue
events.append(ev)
# Sort by start date (ascending, None at the end)
events.sort(key=lambda e: e.get("start") or "9999")
output["total_events"] = len(events)
output["events"] = events
if args.pretty:
print(json.dumps(output, indent=2, ensure_ascii=False))
else:
print(json.dumps(output, ensure_ascii=False))
if __name__ == "__main__":
main()