diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-19 20:46:23 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-19 20:46:23 -0400 |
| commit | d6a995b9090ca35190e59e765d5c14daf887e9d8 (patch) | |
| tree | 9069e0b49068690dad26d69a6cc3a5e1d46f2b25 | |
| parent | 8911d161f7ef38f8a1b03fba6316b71da1011174 (diff) | |
| download | dotemacs-d6a995b9090ca35190e59e765d5c14daf887e9d8.tar.gz dotemacs-d6a995b9090ca35190e59e765d5c14daf887e9d8.zip | |
feat(calendar-sync): add Python helper for Google Calendar API sync
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-<account>.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.
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | docs/calendar-sync-api-setup.org | 171 | ||||
| -rwxr-xr-x | scripts/calendar_sync_api.py | 323 | ||||
| -rw-r--r-- | tests/fixtures/calendar-sync-api/accepted-with-conference.json | 19 | ||||
| -rw-r--r-- | tests/fixtures/calendar-sync-api/all-day-multi.json | 8 | ||||
| -rw-r--r-- | tests/fixtures/calendar-sync-api/all-day-single.json | 7 | ||||
| -rw-r--r-- | tests/fixtures/calendar-sync-api/declined-recurring.json | 15 | ||||
| -rw-r--r-- | tests/test_calendar_sync_api.py | 250 |
8 files changed, 797 insertions, 0 deletions
@@ -86,6 +86,10 @@ history # --- elisp ruleset --- *.eln +# Python bytecode caches (scripts/, tests/) +__pycache__/ +*.pyc + # Emacs backup and auto-save files *~ \#*\# diff --git a/docs/calendar-sync-api-setup.org b/docs/calendar-sync-api-setup.org new file mode 100644 index 00000000..3d172188 --- /dev/null +++ b/docs/calendar-sync-api-setup.org @@ -0,0 +1,171 @@ +#+TITLE: Google Calendar API Sync — Setup +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-19 + +* What this is for + +The default =calendar-sync.el= fetches each calendar's secret +=.ics= URL. That works for Proton and similar feeds, but Google +drops per-occurrence response statuses from the =.ics= export — +when an event is auto-declined by an Out-of-Office, the master +event in =.ics= still shows =PARTSTAT=ACCEPTED= and the declined +instances inherit it. + +The API path solves this. It calls Google Calendar's REST API +with =singleEvents=True= so each recurrence is expanded +server-side, and every instance carries its own +=attendees[].self.responseStatus= — including OOO declines. + +This setup is a one-time task per machine (the refresh token +persists). Once configured, the sync runs from Emacs the same +way it did with =.ics=. + +* One-time setup + +** 1. Install Python dependencies + +#+begin_src bash +sudo pacman -S python-google-api-python-client python-google-auth-oauthlib +#+end_src + +** 2. Create a Google Cloud OAuth client + +The script needs an OAuth 2.0 Client ID to identify itself to +Google. This is free and per-user. + +1. Open [[https://console.cloud.google.com/][Google Cloud Console]]. +2. Create a new project (top bar, project selector → New + Project). Name it something like =calendar-sync= — it doesn't + matter what it's called. +3. Enable the Calendar API: APIs & Services → Library → search + "Google Calendar API" → Enable. +4. Configure the OAuth consent screen: APIs & Services → OAuth + consent screen. User Type = External, app name = + =calendar-sync=, support email = your address. On the Scopes + page, add =.../auth/calendar.readonly=. On Test Users, add + both your work and personal Gmail addresses. (You don't need + to publish the app; staying in "Testing" mode is fine for + personal use.) +5. Create the client: APIs & Services → Credentials → Create + Credentials → OAuth client ID. Application type = + *Desktop app*. Name = =calendar-sync-desktop=. Download the + resulting JSON. + +** 3. Drop the client secret into place + +#+begin_src bash +mkdir -p ~/.config/calendar-sync +mv ~/Downloads/client_secret_*.json ~/.config/calendar-sync/client_secret.json +chmod 600 ~/.config/calendar-sync/client_secret.json +#+end_src + +** 4. Authorize each account once + +Run the script once per account. It'll open a browser, ask for +read-only calendar access, and write a refresh token alongside +the client secret. + +#+begin_src bash +~/.emacs.d/scripts/calendar_sync_api.py \ + --account work \ + --calendar-id primary \ + --output /tmp/dcal-test.org + +~/.emacs.d/scripts/calendar_sync_api.py \ + --account personal \ + --calendar-id primary \ + --output /tmp/gcal-test.org +#+end_src + +After this you should have: + +#+begin_src +~/.config/calendar-sync/ + client_secret.json + token-work.json + token-personal.json +#+end_src + +Each token file holds a refresh token; the script refreshes the +access token automatically on subsequent runs. + +** 5. Flip =calendar-sync.local.el= over + +Add =:fetcher 'api= entries. Old =:url= entries stay on the +=.ics= path — useful for Proton or any non-Google feed. + +#+begin_src emacs-lisp +(setq calendar-sync-calendars + `((:name "gcal" + :fetcher api + :account "personal" + :calendar-id "primary" + :file ,gcal-file) + (:name "dcal" + :fetcher api + :account "work" + :calendar-id "primary" + :file ,dcal-file) + (:name "pcal" + :fetcher ics + :url "https://calendar.proton.me/api/calendar/v1/url/.../calendar.ics" + :file ,pcal-file))) +#+end_src + +(The Elisp dispatch lives in =modules/calendar-sync.el=. Default +=:fetcher= is =ics= so existing entries keep working without +changes.) + +* Running the script manually + +#+begin_src bash +# Render an account's primary calendar to a file +~/.emacs.d/scripts/calendar_sync_api.py \ + --account work --calendar-id primary --output /tmp/out.org + +# Keep declined events in the output (debugging the filter) +~/.emacs.d/scripts/calendar_sync_api.py \ + --account work --calendar-id primary --output /tmp/out.org \ + --keep-declined + +# Different time window +~/.emacs.d/scripts/calendar_sync_api.py \ + --account work --calendar-id primary --output /tmp/out.org \ + --past-months 1 --future-months 6 +#+end_src + +* Calendar IDs + +For most cases, =primary= is what you want. To sync a secondary +calendar (e.g., DeepSat Team Travel), get its ID from Google +Calendar settings → Integrate calendar → Calendar ID. Looks +like =c_<hash>@group.calendar.google.com=. + +* Running the tests + +#+begin_src bash +cd ~/.emacs.d +python3 -m unittest tests/test_calendar_sync_api.py +#+end_src + +30 tests covering rendering, filtering, timestamp formatting, +HTML cleanup, and the full event → org pipeline. Auth and +network calls are intentionally not covered — they're thin +wrappers around the Google libraries and best verified by +running step 4 above. + +* Troubleshooting + +- *"Missing Python dependency"* — install the pacman packages + from step 1. +- *Browser tab won't open during auth* — set + =BROWSER= environment variable to your preferred browser, or + copy the URL the script prints and open it manually. +- *"This app isn't verified"* — expected since you didn't go + through Google's verification process. Click Advanced → Go to + =calendar-sync= (unsafe). You are the developer; you're + trusting yourself. +- *Token expired or revoked* — delete the affected + =token-<account>.json= and run the script once to re-auth. +- *Wrong calendar* — pass =--calendar-id= explicitly. =primary= + refers to whichever account =--account= picks. diff --git a/scripts/calendar_sync_api.py b/scripts/calendar_sync_api.py new file mode 100755 index 00000000..254d40d5 --- /dev/null +++ b/scripts/calendar_sync_api.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Fetch Google Calendar events via the API and emit org-mode entries. + +Usage: + calendar_sync_api.py --account work --calendar-id primary --output FILE + +First-run auth (per account): + 1. Create a Google Cloud OAuth client (Desktop application) and + download client_secret.json to ~/.config/calendar-sync/. + 2. Run the script. A browser tab opens; grant the readonly + calendar scope. The refresh token is written to + ~/.config/calendar-sync/token-<account>.json. + 3. Subsequent runs refresh the access token without a browser. + +This script exists because the secret-URL .ics export drops per- +occurrence response statuses for recurring events (Google's OOO +auto-decline writes only on the API side). The API path expands +recurrences server-side via singleEvents=True and returns one event +per occurrence, each carrying its own attendees[].self.responseStatus. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import html +import os +import re +import sys +from pathlib import Path + +WEEKDAY_ABBREV = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + +# --- Attendee status --------------------------------------------------------- + + +def self_response_status(event): + """Return the authenticated user's responseStatus for EVENT, or None.""" + for attendee in event.get("attendees", []) or []: + if attendee.get("self"): + return attendee.get("responseStatus") + return None + + +def filter_declined(events, skip_declined=True): + """Return EVENTS minus self-declined entries when SKIP_DECLINED is true.""" + if not skip_declined: + return list(events) + return [e for e in events if self_response_status(e) != "declined"] + + +# --- Timestamp formatting ---------------------------------------------------- + + +def _format_date(d): + return f"{d.isoformat()} {WEEKDAY_ABBREV[d.weekday()]}" + + +def format_org_timestamp(start, end): + """Render an org timestamp for a Google API start/end block pair. + + Timed same-day: <YYYY-MM-DD Day HH:MM-HH:MM> + Timed open-ended: <YYYY-MM-DD Day HH:MM> + Timed cross-day: <YYYY-MM-DD Day HH:MM>--<YYYY-MM-DD Day HH:MM> + All-day single: <YYYY-MM-DD Day> + All-day multi: <YYYY-MM-DD Day>--<YYYY-MM-DD Day> (end is inclusive) + """ + if "dateTime" in start: + s = dt.datetime.fromisoformat(start["dateTime"]) + if end and "dateTime" in end: + e = dt.datetime.fromisoformat(end["dateTime"]) + if s.date() == e.date(): + return (f"<{_format_date(s.date())} " + f"{s:%H:%M}-{e:%H:%M}>") + return (f"<{_format_date(s.date())} {s:%H:%M}>--" + f"<{_format_date(e.date())} {e:%H:%M}>") + return f"<{_format_date(s.date())} {s:%H:%M}>" + + # All-day: the Google API uses end.date as an exclusive upper bound. + start_date = dt.date.fromisoformat(start["date"]) + if end and "date" in end: + last_inclusive = dt.date.fromisoformat(end["date"]) - dt.timedelta(days=1) + if start_date == last_inclusive: + return f"<{_format_date(start_date)}>" + return f"<{_format_date(start_date)}>--<{_format_date(last_inclusive)}>" + return f"<{_format_date(start_date)}>" + + +# --- Text cleaning ----------------------------------------------------------- + +_HTML_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"[ \t\r\n]+") +_LEADING_STARS_RE = re.compile(r"^(\*+) ", flags=re.MULTILINE) + + +def strip_html(text): + """Remove HTML tags and decode entities. Returns None for None input.""" + if text is None: + return None + text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE) + text = _HTML_TAG_RE.sub("", text) + return html.unescape(text) + + +def clean_text(text): + """Strip HTML, collapse 3+ blank lines, trim.""" + if not text: + return text + cleaned = strip_html(text) or "" + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() + + +def sanitize_body_text(text): + """Neutralize leading asterisks so external text isn't parsed as Org headings.""" + if not text: + return text + return _LEADING_STARS_RE.sub(lambda m: "-" * len(m.group(1)) + " ", text) + + +def sanitize_property_value(text): + """Collapse whitespace + newlines to single spaces, trim.""" + if not text: + return text + return _WS_RE.sub(" ", text).strip() + + +def sanitize_heading(text): + """Mirrors cj/org-sanitize-heading: neutralize stars, flatten whitespace.""" + return sanitize_property_value(sanitize_body_text(text)) + + +# --- Event field extraction -------------------------------------------------- + + +def extract_organizer(event): + """Return organizer display name or email, or None.""" + org = event.get("organizer") or {} + return org.get("displayName") or org.get("email") + + +def extract_meeting_url(event): + """Prefer conferenceData video entryPoint, fall back to hangoutLink.""" + cd = event.get("conferenceData") or {} + for ep in cd.get("entryPoints", []) or []: + if ep.get("entryPointType") == "video": + uri = ep.get("uri") + if uri: + return uri + return event.get("hangoutLink") + + +# --- Rendering --------------------------------------------------------------- + + +def render_event(event): + """Render one Google Calendar event as an org entry, or None to skip.""" + start = event.get("start") + if not start: + return None + + summary = event.get("summary") or "(No Title)" + heading = sanitize_heading(summary) or "(No Title)" + timestamp = format_org_timestamp(start, event.get("end")) + + props = [] + location = event.get("location") + if location: + props.append(f":LOCATION: {sanitize_property_value(location)}") + organizer = extract_organizer(event) + if organizer: + props.append(f":ORGANIZER: {sanitize_property_value(organizer)}") + status = self_response_status(event) + if status: + props.append(f":STATUS: {sanitize_property_value(status)}") + url = extract_meeting_url(event) + if url: + props.append(f":URL: {sanitize_property_value(url)}") + + parts = [f"* {heading}", timestamp] + if props: + parts.append(":PROPERTIES:") + parts.extend(props) + parts.append(":END:") + + description = clean_text(event.get("description")) + if description: + parts.append(sanitize_body_text(description)) + + return "\n".join(parts) + + +def render_calendar(events, header="# Calendar Events"): + """Render every event in EVENTS as an org file body.""" + chunks = [header, ""] + for event in events: + org = render_event(event) + if org: + chunks.append(org) + chunks.append("") + return "\n".join(chunks) + "\n" + + +# --- OAuth + fetch ----------------------------------------------------------- + + +def _config_dir(): + override = os.environ.get("CALENDAR_SYNC_CONFIG_DIR") + if override: + return Path(override) + return Path.home() / ".config" / "calendar-sync" + + +SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] + + +def load_credentials(account): + """Load OAuth credentials for ACCOUNT; refresh or run flow as needed.""" + try: + from google.auth.transport.requests import Request + from google.oauth2.credentials import Credentials + from google_auth_oauthlib.flow import InstalledAppFlow + except ImportError as e: + raise SystemExit( + f"ERROR: Missing Python dependency ({e.name}).\n" + "Install with: sudo pacman -S python-google-api-python-client " + "python-google-auth-oauthlib" + ) + + config_dir = _config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + + client_secret = config_dir / "client_secret.json" + token_file = config_dir / f"token-{account}.json" + creds = None + if token_file.exists(): + creds = Credentials.from_authorized_user_file(str(token_file), SCOPES) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + if not client_secret.exists(): + raise SystemExit( + f"ERROR: {client_secret} not found.\n" + "See docs/calendar-sync-api-setup.org for the OAuth setup." + ) + flow = InstalledAppFlow.from_client_secrets_file(str(client_secret), SCOPES) + creds = flow.run_local_server(port=0) + token_file.write_text(creds.to_json()) + os.chmod(token_file, 0o600) + return creds + + +def fetch_events(creds, calendar_id, past_months=3, future_months=12): + """Fetch all events from CALENDAR_ID over the configured window.""" + try: + from googleapiclient.discovery import build + except ImportError as e: + raise SystemExit( + f"ERROR: Missing Python dependency ({e.name}).\n" + "Install with: sudo pacman -S python-google-api-python-client" + ) + + service = build("calendar", "v3", credentials=creds, cache_discovery=False) + now = dt.datetime.now(dt.timezone.utc) + time_min = (now - dt.timedelta(days=30 * past_months)).isoformat() + time_max = (now + dt.timedelta(days=30 * future_months)).isoformat() + events = [] + page_token = None + while True: + resp = service.events().list( + calendarId=calendar_id, + timeMin=time_min, + timeMax=time_max, + singleEvents=True, + orderBy="startTime", + pageToken=page_token, + maxResults=2500, + ).execute() + events.extend(resp.get("items", [])) + page_token = resp.get("nextPageToken") + if not page_token: + break + return events + + +# --- CLI --------------------------------------------------------------------- + + +_CLI_DESCRIPTION = ( + "Fetch Google Calendar events via the API and emit org-mode entries." +) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=_CLI_DESCRIPTION) + parser.add_argument("--account", required=True, + help="OAuth account nickname (work, personal, ...).") + parser.add_argument("--calendar-id", required=True, + help="Calendar ID (e.g. 'primary' or a long ID).") + parser.add_argument("--output", required=True, type=Path, + help="Path to write the rendered org file.") + parser.add_argument("--past-months", type=int, default=3) + parser.add_argument("--future-months", type=int, default=12) + parser.add_argument("--keep-declined", action="store_true", + help="Skip the self-declined filter (default: filter on).") + args = parser.parse_args(argv) + + creds = load_credentials(args.account) + events = fetch_events(creds, args.calendar_id, + past_months=args.past_months, + future_months=args.future_months) + events = filter_declined(events, skip_declined=not args.keep_declined) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_calendar(events)) + sys.stderr.write(f"calendar-sync-api: wrote {len(events)} events to {args.output}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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.<br/><br/>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 <b>world</b>"), "Hello world") + + def test_normal_converts_br_to_newline(self): + self.assertEqual(api.strip_html("Line 1<br>Line 2"), "Line 1\nLine 2") + self.assertEqual(api.strip_html("Line 1<br/>Line 2"), "Line 1\nLine 2") + self.assertEqual(api.strip_html("Line 1<BR />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 <br/> got normalized to newlines, not left as raw tags. + self.assertNotIn("<br", out) + + def test_normal_renders_all_day_event(self): + ev = load_fixture("all-day-multi.json") + out = api.render_event(ev) + self.assertIn("* SOFWeek — Tampa", out) + self.assertIn("<2026-05-17 Sun>--<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() |
