aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xscripts/google-keep/keep-bridge.py92
-rw-r--r--scripts/google-keep/test_keep_bridge.py152
2 files changed, 244 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()
diff --git a/scripts/google-keep/test_keep_bridge.py b/scripts/google-keep/test_keep_bridge.py
new file mode 100644
index 000000000..a24132744
--- /dev/null
+++ b/scripts/google-keep/test_keep_bridge.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""Tests for keep-bridge's pure shaping helpers + its failure degradation.
+
+The gkeepapi auth/fetch path is the IO boundary and is exercised live once the
+token is configured; here we test the JSON-shaping logic (the round-trip
+contract the elisp side reads) with duck-typed stand-ins, plus a subprocess
+smoke test that the script degrades with a reason token rather than crashing.
+
+Run: python3 -m unittest test_keep_bridge (from scripts/google-keep/)
+"""
+
+import importlib.util
+import os
+import subprocess
+import sys
+import unittest
+from datetime import datetime, timezone, timedelta
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_BRIDGE = os.path.join(_HERE, "keep-bridge.py")
+
+_spec = importlib.util.spec_from_file_location("keep_bridge", _BRIDGE)
+assert _spec and _spec.loader
+kb = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(kb)
+
+
+# --- duck-typed stand-ins for a gkeepapi note ---------------------------------
+
+class FakeLabel:
+ def __init__(self, name):
+ self.name = name
+
+
+class FakeLabels:
+ def __init__(self, names):
+ self._labels = [FakeLabel(n) for n in names]
+
+ def all(self):
+ return self._labels
+
+
+class FakeTimestamps:
+ def __init__(self, updated):
+ self.updated = updated
+
+
+class FakeColor:
+ def __init__(self, value):
+ self.value = value
+
+
+class FakeNote:
+ def __init__(self, id="n1", title: object = "T", text: object = "B", labels=(),
+ pinned=False, archived=False, color: object = "WHITE", updated=None):
+ self.id = id
+ self.title = title
+ self.text = text
+ self.labels = FakeLabels(labels)
+ self.pinned = pinned
+ self.archived = archived
+ self.color = color
+ self.timestamps = FakeTimestamps(updated)
+
+
+class TestIso8601Utc(unittest.TestCase):
+ def test_normal_naive_datetime_treated_as_utc(self):
+ self.assertEqual(kb.iso8601_utc(datetime(2026, 6, 25, 4, 12, 0)),
+ "2026-06-25T04:12:00Z")
+
+ def test_normal_aware_datetime_converted_to_utc(self):
+ est = timezone(timedelta(hours=-5))
+ self.assertEqual(kb.iso8601_utc(datetime(2026, 6, 24, 23, 12, 0, tzinfo=est)),
+ "2026-06-25T04:12:00Z")
+
+ def test_boundary_none_returns_none(self):
+ self.assertIsNone(kb.iso8601_utc(None))
+
+
+class TestColorName(unittest.TestCase):
+ def test_normal_enum_with_value(self):
+ self.assertEqual(kb.color_name(FakeColor("RED")), "RED")
+
+ def test_normal_plain_string(self):
+ self.assertEqual(kb.color_name("WHITE"), "WHITE")
+
+ def test_boundary_name_only_object(self):
+ class C:
+ name = "BLUE"
+ self.assertEqual(kb.color_name(C()), "BLUE")
+
+
+class TestNoteToDict(unittest.TestCase):
+ def test_normal_full_note(self):
+ note = FakeNote(id="abc", title="Groceries", text="milk\neggs",
+ labels=("shopping", "home"), pinned=True, archived=False,
+ color=FakeColor("YELLOW"),
+ updated=datetime(2026, 6, 25, 4, 0, 0, tzinfo=timezone.utc))
+ self.assertEqual(kb.note_to_dict(note), {
+ "id": "abc",
+ "title": "Groceries",
+ "text": "milk\neggs",
+ "labels": ["shopping", "home"],
+ "pinned": True,
+ "archived": False,
+ "color": "YELLOW",
+ "updated": "2026-06-25T04:00:00Z",
+ })
+
+ def test_boundary_empty_title_and_no_labels(self):
+ note = FakeNote(title="", labels=(), color="WHITE",
+ updated=datetime(2026, 1, 1, tzinfo=timezone.utc))
+ d = kb.note_to_dict(note)
+ self.assertEqual(d["title"], "")
+ self.assertEqual(d["labels"], [])
+
+ def test_boundary_none_title_text_coerced_to_empty(self):
+ note = FakeNote(title=None, text=None, color="WHITE",
+ updated=datetime(2026, 1, 1, tzinfo=timezone.utc))
+ d = kb.note_to_dict(note)
+ self.assertEqual(d["title"], "")
+ self.assertEqual(d["text"], "")
+
+
+class TestNotesToJson(unittest.TestCase):
+ def test_normal_array_of_notes(self):
+ import json
+ notes = [FakeNote(id="a", updated=datetime(2026, 1, 1, tzinfo=timezone.utc)),
+ FakeNote(id="b", updated=datetime(2026, 1, 2, tzinfo=timezone.utc))]
+ parsed = json.loads(kb.notes_to_json(notes))
+ self.assertEqual([n["id"] for n in parsed], ["a", "b"])
+
+ def test_boundary_empty_keep_is_empty_array(self):
+ self.assertEqual(kb.notes_to_json([]), "[]")
+
+
+class TestDegradation(unittest.TestCase):
+ def test_error_no_env_exits_nonzero_with_reason_token(self):
+ # With no KEEP_EMAIL/KEEP_MASTER_TOKEN the script must exit non-zero
+ # with a single reason token, never crash. The exact token depends on
+ # whether gkeepapi is installed in this environment.
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("KEEP_EMAIL", "KEEP_MASTER_TOKEN")}
+ proc = subprocess.run([sys.executable, _BRIDGE], env=env,
+ capture_output=True, text=True)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn(proc.stderr.strip(), ("no-gkeepapi", "no-token"))
+ self.assertEqual(proc.stdout, "")
+
+
+if __name__ == "__main__":
+ unittest.main()