aboutsummaryrefslogtreecommitdiff
path: root/scripts/google-keep/keep-bridge.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-25 00:52:55 -0400
committerCraig Jennings <c@cjennings.net>2026-06-25 00:52:55 -0400
commitaac7bff8335d43408ef62293f0beb8c8f7a8165d (patch)
treef2334908e0e503f00eab0b2115c601ead590aab9 /scripts/google-keep/keep-bridge.py
parentcf22bcfd6baade4bd2030523e60c168b42530345 (diff)
downloaddotemacs-aac7bff8335d43408ef62293f0beb8c8f7a8165d.tar.gz
dotemacs-aac7bff8335d43408ef62293f0beb8c8f7a8165d.zip
feat(google-keep): gkeepapi data bridge with JSON contract (Phase 1)
Diffstat (limited to 'scripts/google-keep/keep-bridge.py')
-rwxr-xr-xscripts/google-keep/keep-bridge.py92
1 files changed, 92 insertions, 0 deletions
diff --git a/scripts/google-keep/keep-bridge.py b/scripts/google-keep/keep-bridge.py
new file mode 100755
index 000000000..ef1fdd75a
--- /dev/null
+++ b/scripts/google-keep/keep-bridge.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""keep-bridge -- fetch Google Keep notes via gkeepapi and emit JSON.
+
+The one place the unofficial Google Keep API lives, isolated so a break is
+contained and the elisp renderer talks only to this script's JSON contract.
+See docs/specs/google-keep-emacs-integration-spec.org (Bridge JSON schema).
+
+Reads two environment variables (set by the elisp caller, which pulls the
+token from authinfo.gpg via auth-source):
+
+ KEEP_EMAIL the Google account email
+ KEEP_MASTER_TOKEN the gkeepapi master token
+
+On success: prints a JSON array of note objects on stdout, exits 0. An empty
+Keep prints "[]". On failure: exits non-zero with one reason token on stderr,
+which the elisp sentinel maps to a display-warning:
+
+ no-gkeepapi gkeepapi is not importable
+ no-token KEEP_MASTER_TOKEN or KEEP_EMAIL is unset
+ auth-failed gkeepapi rejected the credentials
+ network a network/other error reaching Keep
+"""
+
+import json
+import os
+import sys
+from datetime import timezone
+from typing import NoReturn
+
+
+def iso8601_utc(dt):
+ """Format DT (a datetime) as ISO8601 UTC with a trailing Z, or None."""
+ if dt is None:
+ return None
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def color_name(color):
+ """Return the Keep color as a plain string from a gkeepapi enum or a string."""
+ return getattr(color, "value", None) or getattr(color, "name", None) or str(color)
+
+
+def note_to_dict(note):
+ """Shape one gkeepapi note (or a duck-typed stand-in) into the schema dict."""
+ return {
+ "id": note.id,
+ "title": note.title or "",
+ "text": note.text or "",
+ "labels": [label.name for label in note.labels.all()],
+ "pinned": bool(note.pinned),
+ "archived": bool(note.archived),
+ "color": color_name(note.color),
+ "updated": iso8601_utc(note.timestamps.updated),
+ }
+
+
+def notes_to_json(notes):
+ """Serialize an iterable of NOTES to the schema JSON string."""
+ return json.dumps([note_to_dict(n) for n in notes], ensure_ascii=False)
+
+
+def _fail(token) -> NoReturn:
+ sys.stderr.write(token + "\n")
+ sys.exit(1)
+
+
+def main():
+ try:
+ import gkeepapi # type: ignore[import] # optional runtime dep
+ except ImportError:
+ _fail("no-gkeepapi")
+ email = os.environ.get("KEEP_EMAIL")
+ token = os.environ.get("KEEP_MASTER_TOKEN")
+ if not email or not token:
+ _fail("no-token")
+ keep = gkeepapi.Keep()
+ try:
+ keep.resume(email, token)
+ except Exception as exc: # gkeepapi raises LoginException on bad credentials
+ _fail("auth-failed" if type(exc).__name__ == "LoginException" else "network")
+ try:
+ keep.sync()
+ notes = list(keep.all())
+ except Exception:
+ _fail("network")
+ sys.stdout.write(notes_to_json(notes))
+
+
+if __name__ == "__main__":
+ main()