fetch_busy_ics.py 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. #!/usr/bin/env python3
  2. """Fetch a public CalDAV calendar and write a combined ICS to stdout or a file.
  3. Sends a CalDAV REPORT to the given URL, extracts all VEVENT components, and
  4. combines them into a single VCALENDAR document. No authentication required.
  5. Usage:
  6. python scripts/fetch_busy_ics.py [--url URL] [-o FILE]
  7. """
  8. import argparse
  9. import sys
  10. import xml.etree.ElementTree as ET
  11. import httpx
  12. from icalendar import Calendar, vText
  13. DEFAULT_URL = (
  14. 'https://dav.kj.zi3.fi/dav/calendars/'
  15. 'blonde.neutral.jazz.legacy.episode.phrase@zi3.xyz/'
  16. '45911EE3-80B1-4D3E-9D07-20BA775E110F'
  17. )
  18. _REPORT_BODY = b"""\
  19. <?xml version="1.0" encoding="utf-8" ?>
  20. <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  21. <D:prop>
  22. <D:getetag/>
  23. <C:calendar-data/>
  24. </D:prop>
  25. <C:filter>
  26. <C:comp-filter name="VCALENDAR">
  27. <C:comp-filter name="VEVENT"/>
  28. </C:comp-filter>
  29. </C:filter>
  30. </C:calendar-query>"""
  31. _DAV_NS = 'DAV:'
  32. _CALDAV_NS = 'urn:ietf:params:xml:ns:caldav'
  33. PRODID = '-//room_cal//Busy//EN'
  34. def fetch_calendar_data(url: str) -> list[str]:
  35. """Send a CalDAV REPORT and return one iCal string per event resource."""
  36. url = url.rstrip('/') + '/'
  37. resp = httpx.request(
  38. 'REPORT',
  39. url,
  40. content=_REPORT_BODY,
  41. headers={
  42. 'Content-Type': 'application/xml; charset=utf-8',
  43. 'Depth': '1',
  44. },
  45. )
  46. resp.raise_for_status()
  47. root = ET.fromstring(resp.content)
  48. results = []
  49. for response in root.iter(f'{{{_DAV_NS}}}response'):
  50. for cal_data in response.iter(f'{{{_CALDAV_NS}}}calendar-data'):
  51. if cal_data.text:
  52. results.append(cal_data.text)
  53. return results
  54. def build_ics(cal_data_list: list[str]) -> bytes:
  55. """Parse each calendar-data string and merge all VEVENTs into one VCALENDAR."""
  56. combined = Calendar()
  57. combined.add('prodid', PRODID)
  58. combined.add('version', '2.0')
  59. combined.add('calscale', vText('GREGORIAN'))
  60. combined.add('method', vText('PUBLISH'))
  61. for cal_text in cal_data_list:
  62. cal = Calendar.from_ical(cal_text)
  63. for component in cal.walk():
  64. if component.name == 'VEVENT':
  65. combined.add_component(component)
  66. return combined.to_ical()
  67. def main() -> None:
  68. parser = argparse.ArgumentParser(
  69. description=__doc__,
  70. formatter_class=argparse.RawDescriptionHelpFormatter,
  71. )
  72. parser.add_argument('--url', default=DEFAULT_URL, help='CalDAV calendar URL (default: Busy calendar)')
  73. parser.add_argument('-o', '--output', default=None, metavar='FILE', help='Output file (default: stdout)')
  74. args = parser.parse_args()
  75. cal_data_list = fetch_calendar_data(args.url)
  76. ics = build_ics(cal_data_list)
  77. if args.output:
  78. with open(args.output, 'wb') as f:
  79. f.write(ics)
  80. print(f'{len(cal_data_list)} event(s) written to {args.output}', file=sys.stderr)
  81. else:
  82. sys.stdout.buffer.write(ics)
  83. if __name__ == '__main__':
  84. main()