1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
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())
|