From d6a995b9090ca35190e59e765d5c14daf887e9d8 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 19 May 2026 20:46:23 -0400 Subject: feat(calendar-sync): add Python helper for Google Calendar API sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google's .ics export drops per-occurrence response statuses on recurring events. When OOO auto-declines a meeting, the master event keeps PARTSTAT=ACCEPTED and declined instances inherit it. The .ics path can't filter the declines out. The API path expands recurrences server-side via singleEvents=True, and each occurrence carries its own attendees[].self.responseStatus. scripts/calendar_sync_api.py fetches events and renders them as org entries. OAuth is one-time per account. The refresh token lives at ~/.config/calendar-sync/token-.json under 0600. Output matches the existing .ics shape: heading sanitization, LOCATION/ORGANIZER/STATUS/URL property drawer, HTML-stripped descriptions, org timestamps with weekday abbreviations. I wrote 30 stdlib-unittest tests against fixture JSON, covering rendering, filtering, timestamp formatting, and HTML cleanup. I left auth and HTTP uncovered — they're thin wrappers around the Google client libraries, best checked by running the script once after OAuth setup. docs/calendar-sync-api-setup.org walks through the Google Cloud OAuth client setup and the per-account auth bootstrap. .gitignore picks up Python bytecode now that the project has a Python helper. The Elisp dispatch (:fetcher 'api routing in calendar-sync.el) lands in a follow-up commit. --- .../accepted-with-conference.json | 19 ++ .../fixtures/calendar-sync-api/all-day-multi.json | 8 + .../fixtures/calendar-sync-api/all-day-single.json | 7 + .../calendar-sync-api/declined-recurring.json | 15 ++ tests/test_calendar_sync_api.py | 250 +++++++++++++++++++++ 5 files changed, 299 insertions(+) create mode 100644 tests/fixtures/calendar-sync-api/accepted-with-conference.json create mode 100644 tests/fixtures/calendar-sync-api/all-day-multi.json create mode 100644 tests/fixtures/calendar-sync-api/all-day-single.json create mode 100644 tests/fixtures/calendar-sync-api/declined-recurring.json create mode 100644 tests/test_calendar_sync_api.py (limited to 'tests') diff --git a/tests/fixtures/calendar-sync-api/accepted-with-conference.json b/tests/fixtures/calendar-sync-api/accepted-with-conference.json new file mode 100644 index 00000000..63d4e0be --- /dev/null +++ b/tests/fixtures/calendar-sync-api/accepted-with-conference.json @@ -0,0 +1,19 @@ +{ + "id": "6hh36pj364sjibb161i3gb9kckr32bb161hjebb671hjcp9g6so68ob5c8", + "summary": "Breakout: Machine Speed, Human Will", + "location": "Marriott Water Street: Florida Salon V - VI / Breakout Room 2", + "start": {"dateTime": "2026-05-19T14:45:00-04:00", "timeZone": "America/New_York"}, + "end": {"dateTime": "2026-05-19T15:45:00-04:00", "timeZone": "America/New_York"}, + "description": "AI's role in the future of special operations.

Bring your laptop.", + "status": "confirmed", + "organizer": {"email": "eric@deepsat.com", "displayName": "Eric Bell"}, + "attendees": [ + {"email": "craig.jennings@deepsat.com", "responseStatus": "accepted", "self": true}, + {"email": "eric@deepsat.com", "responseStatus": "accepted", "organizer": true} + ], + "conferenceData": { + "entryPoints": [ + {"entryPointType": "video", "uri": "https://meet.google.com/abc-defg-hij"} + ] + } +} diff --git a/tests/fixtures/calendar-sync-api/all-day-multi.json b/tests/fixtures/calendar-sync-api/all-day-multi.json new file mode 100644 index 00000000..96d9e0ed --- /dev/null +++ b/tests/fixtures/calendar-sync-api/all-day-multi.json @@ -0,0 +1,8 @@ +{ + "id": "fjamsabuds9porc82vh8r7tsl4", + "summary": "SOFWeek — Tampa", + "location": "Tampa, FL", + "start": {"date": "2026-05-17"}, + "end": {"date": "2026-05-22"}, + "status": "confirmed" +} diff --git a/tests/fixtures/calendar-sync-api/all-day-single.json b/tests/fixtures/calendar-sync-api/all-day-single.json new file mode 100644 index 00000000..99d2a2a2 --- /dev/null +++ b/tests/fixtures/calendar-sync-api/all-day-single.json @@ -0,0 +1,7 @@ +{ + "id": "2jn8q2k837d2v29alsvcfqljek_20260519", + "summary": "Home", + "start": {"date": "2026-05-19"}, + "end": {"date": "2026-05-20"}, + "status": "confirmed" +} diff --git a/tests/fixtures/calendar-sync-api/declined-recurring.json b/tests/fixtures/calendar-sync-api/declined-recurring.json new file mode 100644 index 00000000..fad6a862 --- /dev/null +++ b/tests/fixtures/calendar-sync-api/declined-recurring.json @@ -0,0 +1,15 @@ +{ + "id": "4lu91bb3spgmru2bfndh0a4rrk_20260519T160000Z", + "summary": "STRATFI/FR Standup/IPM/Grooming", + "start": {"dateTime": "2026-05-19T12:00:00-04:00", "timeZone": "America/Los_Angeles"}, + "end": {"dateTime": "2026-05-19T13:00:00-04:00", "timeZone": "America/Los_Angeles"}, + "status": "confirmed", + "organizer": {"email": "ryan@deepsat.com"}, + "attendees": [ + {"email": "nerses@deepsat.com", "responseStatus": "accepted"}, + {"email": "ryan@deepsat.com", "responseStatus": "declined", "organizer": true}, + {"email": "craig.jennings@deepsat.com", "responseStatus": "declined", "self": true, + "comment": "Out of office for SOFWeek (Tampa) through Friday 5/22."} + ], + "hangoutLink": "https://meet.google.com/suw-ornf-iuv" +} diff --git a/tests/test_calendar_sync_api.py b/tests/test_calendar_sync_api.py new file mode 100644 index 00000000..36b0a14a --- /dev/null +++ b/tests/test_calendar_sync_api.py @@ -0,0 +1,250 @@ +"""Tests for the Google Calendar API sync helper. + +Pure rendering functions only — auth and fetch are thin wrappers +around the Google libraries and get smoke-tested manually after +OAuth setup. + +Run: python3 -m unittest tests/test_calendar_sync_api.py +""" + +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +# Add scripts/ to path so we can import the helper as a module. +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import calendar_sync_api as api # noqa: E402 + +FIXTURES = Path(__file__).parent / "fixtures" / "calendar-sync-api" + + +def load_fixture(name): + return json.loads((FIXTURES / name).read_text()) + + +# --- self_response_status --- + +class TestSelfResponseStatus(unittest.TestCase): + """Normal/Boundary/Error coverage for self_response_status.""" + + def test_normal_finds_self_declined(self): + ev = load_fixture("declined-recurring.json") + self.assertEqual(api.self_response_status(ev), "declined") + + def test_normal_finds_self_accepted(self): + ev = load_fixture("accepted-with-conference.json") + self.assertEqual(api.self_response_status(ev), "accepted") + + def test_boundary_no_attendees_returns_none(self): + ev = load_fixture("all-day-multi.json") + self.assertIsNone(api.self_response_status(ev)) + + def test_boundary_attendees_without_self_flag_returns_none(self): + ev = {"attendees": [ + {"email": "other@example.com", "responseStatus": "accepted"}, + ]} + self.assertIsNone(api.self_response_status(ev)) + + def test_error_missing_attendees_key(self): + self.assertIsNone(api.self_response_status({})) + + +# --- filter_declined --- + +class TestFilterDeclined(unittest.TestCase): + + def setUp(self): + self.events = [ + load_fixture("declined-recurring.json"), + load_fixture("accepted-with-conference.json"), + load_fixture("all-day-multi.json"), + ] + + def test_normal_drops_self_declined(self): + result = api.filter_declined(self.events, skip_declined=True) + self.assertEqual(len(result), 2) + for e in result: + self.assertNotEqual(api.self_response_status(e), "declined") + + def test_normal_toggle_off_keeps_all(self): + result = api.filter_declined(self.events, skip_declined=False) + self.assertEqual(len(result), 3) + + def test_boundary_empty_list(self): + self.assertEqual(api.filter_declined([], skip_declined=True), []) + + def test_boundary_none_declined(self): + events = [load_fixture("accepted-with-conference.json")] + self.assertEqual(len(api.filter_declined(events)), 1) + + +# --- format_org_timestamp --- + +class TestFormatOrgTimestamp(unittest.TestCase): + + def test_normal_timed_same_day(self): + start = {"dateTime": "2026-05-19T14:45:00-04:00"} + end = {"dateTime": "2026-05-19T15:45:00-04:00"} + # Expected uses the offset's local time directly (no TZ conversion + # needed when the dateTime already carries its offset). + self.assertEqual( + api.format_org_timestamp(start, end), + "<2026-05-19 Tue 14:45-15:45>", + ) + + def test_normal_all_day_single(self): + start = {"date": "2026-05-19"} + end = {"date": "2026-05-20"} + self.assertEqual( + api.format_org_timestamp(start, end), + "<2026-05-19 Tue>", + ) + + def test_boundary_all_day_multi(self): + start = {"date": "2026-05-17"} + end = {"date": "2026-05-22"} + # End is exclusive in the Google API; last inclusive day is 5/21. + self.assertEqual( + api.format_org_timestamp(start, end), + "<2026-05-17 Sun>--<2026-05-21 Thu>", + ) + + def test_boundary_timed_no_end(self): + start = {"dateTime": "2026-05-19T14:45:00-04:00"} + self.assertEqual( + api.format_org_timestamp(start, None), + "<2026-05-19 Tue 14:45>", + ) + + +# --- HTML / text cleaning --- + +class TestStripHtml(unittest.TestCase): + + def test_normal_strips_tags(self): + self.assertEqual(api.strip_html("Hello world"), "Hello world") + + def test_normal_converts_br_to_newline(self): + self.assertEqual(api.strip_html("Line 1
Line 2"), "Line 1\nLine 2") + self.assertEqual(api.strip_html("Line 1
Line 2"), "Line 1\nLine 2") + self.assertEqual(api.strip_html("Line 1
Line 2"), "Line 1\nLine 2") + + def test_normal_decodes_entities(self): + self.assertEqual(api.strip_html("AT&T "hi""), 'AT&T "hi"') + + def test_boundary_empty_string(self): + self.assertEqual(api.strip_html(""), "") + + def test_error_none_input(self): + self.assertIsNone(api.strip_html(None)) + + +class TestSanitizeHeading(unittest.TestCase): + + def test_normal_collapses_whitespace(self): + self.assertEqual(api.sanitize_heading("a b\n\tc"), "a b c") + + def test_normal_neutralizes_leading_stars(self): + # Leading "* " in heading text would create a nested heading. + self.assertEqual(api.sanitize_heading("* foo"), "- foo") + + def test_boundary_empty(self): + self.assertEqual(api.sanitize_heading(""), "") + + +# --- extract_meeting_url --- + +class TestExtractMeetingUrl(unittest.TestCase): + + def test_normal_prefers_conference_data_video(self): + ev = load_fixture("accepted-with-conference.json") + self.assertEqual( + api.extract_meeting_url(ev), + "https://meet.google.com/abc-defg-hij", + ) + + def test_normal_falls_back_to_hangout_link(self): + ev = load_fixture("declined-recurring.json") + self.assertEqual( + api.extract_meeting_url(ev), + "https://meet.google.com/suw-ornf-iuv", + ) + + def test_boundary_no_meeting_url(self): + ev = load_fixture("all-day-multi.json") + self.assertIsNone(api.extract_meeting_url(ev)) + + +# --- render_event --- + +class TestRenderEvent(unittest.TestCase): + + def test_normal_renders_accepted_meeting(self): + ev = load_fixture("accepted-with-conference.json") + out = api.render_event(ev) + # Heading + timestamp + property drawer + description body + self.assertIn("* Breakout: Machine Speed, Human Will", out) + self.assertIn("<2026-05-19 Tue 14:45-15:45>", out) + self.assertIn(":LOCATION: Marriott Water Street: Florida Salon V - VI / Breakout Room 2", out) + self.assertIn(":ORGANIZER: Eric Bell", out) + self.assertIn(":STATUS: accepted", out) + self.assertIn(":URL: https://meet.google.com/abc-defg-hij", out) + self.assertIn("Bring your laptop", out) + # HTML
got normalized to newlines, not left as raw tags. + self.assertNotIn("--<2026-05-21 Thu>", out) + self.assertIn(":LOCATION: Tampa, FL", out) + # No :STATUS: when there's no attendee block. + self.assertNotIn(":STATUS:", out) + + def test_normal_renders_minimal_self_event(self): + ev = load_fixture("all-day-single.json") + out = api.render_event(ev) + self.assertIn("* Home", out) + self.assertIn("<2026-05-19 Tue>", out) + # No property drawer at all when none of the optional fields are set. + self.assertNotIn(":PROPERTIES:", out) + + def test_boundary_missing_summary_falls_back(self): + ev = {"start": {"date": "2026-05-19"}, "end": {"date": "2026-05-20"}} + out = api.render_event(ev) + self.assertIn("* (No Title)", out) + + def test_error_missing_start_returns_none(self): + ev = {"summary": "Broken"} + self.assertIsNone(api.render_event(ev)) + + +# --- render_calendar end-to-end with filter --- + +class TestRenderCalendar(unittest.TestCase): + + def test_normal_filters_declined_and_renders_rest(self): + events = [ + load_fixture("declined-recurring.json"), + load_fixture("accepted-with-conference.json"), + load_fixture("all-day-multi.json"), + ] + events = api.filter_declined(events, skip_declined=True) + out = api.render_calendar(events) + # Declined event must not appear. + self.assertNotIn("STRATFI/FR Standup", out) + # Accepted ones must appear. + self.assertIn("Breakout: Machine Speed", out) + self.assertIn("SOFWeek — Tampa", out) + # Header is the same shape the Elisp parser produces. + self.assertTrue(out.startswith("# Calendar Events\n")) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3