aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--docs/calendar-sync-api-setup.org171
-rwxr-xr-xscripts/calendar_sync_api.py323
-rw-r--r--tests/fixtures/calendar-sync-api/accepted-with-conference.json19
-rw-r--r--tests/fixtures/calendar-sync-api/all-day-multi.json8
-rw-r--r--tests/fixtures/calendar-sync-api/all-day-single.json7
-rw-r--r--tests/fixtures/calendar-sync-api/declined-recurring.json15
-rw-r--r--tests/test_calendar_sync_api.py250
8 files changed, 797 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 31f83172..0f9201c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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&amp;T &quot;hi&quot;"), '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()