aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/google-keep/keep-bridge.py92
-rw-r--r--scripts/google-keep/test_keep_bridge.py152
-rw-r--r--scripts/theme-studio/app.js15
-rw-r--r--scripts/theme-studio/browser-gates.js19
-rw-r--r--scripts/theme-studio/theme-studio.html34
5 files changed, 300 insertions, 12 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()
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index d1aa2eb2c..ce1480ffb 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -679,16 +679,23 @@ function buildUITable(){
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.title=composeHoverTitle(FACE_DOCS[face],c0.title);c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.title='flash this face in the live preview';c0lbl.onclick=()=>flashUiPreview(face);c0.appendChild(c0lbl);
+ // Emacs draws the cursor as a rectangle: its fg colors the glyph sitting on
+ // it and its bg is the cursor color, but weight/slant/underline/strike and
+ // box are no-ops on it. Show only fg+bg for the cursor row; mute the rest.
+ const cursorOnly=(face==='cursor');
+ const naCell=t=>{const s=document.createElement('span');s.textContent='—';s.style.opacity='0.4';s.title=t;return s;};
const fgSel=uiSelect(face,'fg'),bgSel=uiSelect(face,'bg');
const cF=document.createElement('td');cF.appendChild(fgSel);
const cB=document.createElement('td');cB.appendChild(bgSel);
const cS=document.createElement('td');
- const stCtls=mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
- const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);
+ const stCtls=cursorOnly?[]:mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
+ if(cursorOnly){cS.appendChild(naCell('Emacs ignores weight/slant/underline/strike on the cursor face'));}
+ else{const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);}
const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt';
const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px';
- const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});cX.appendChild(boxCtl);
- const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
+ const cX=document.createElement('td');const boxCtl=cursorOnly?null:mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});
+ if(cursorOnly){cX.appendChild(naCell('Emacs ignores the box attribute on the cursor face'));}else{cX.appendChild(boxCtl);}
+ const cL=mkLockCell('ui:'+face,cursorOnly?[fgSel,bgSel,...exp.locks]:[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
tr.appendChild(cL);tr.appendChild(c0);tr.appendChild(cF);tr.appendChild(cB);tr.appendChild(cS);tr.appendChild(cX);tr.appendChild(cC);tr.appendChild(cP);tb.appendChild(tr);tb.appendChild(exp.detail);paintUI(face);
}
applyTableSort('uibody');
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 0bc6b2fbd..fcdfaff00 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -221,6 +221,21 @@ if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMA
pickEnum(pkgWeight(),'heavy');
A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited');
}));
+// Cursor-row gate (open with #cursorrowtest): the cursor face honors only fg
+// (the glyph on it) and bg (the cursor color); weight/slant/underline/strike and
+// box are no-ops, so the row mutes them to a dash while non-cursor rows keep them.
+if(location.hash==='#cursorrowtest')gate('cursorrowtest',A=>{
+ buildUITable();
+ const rows=[...document.querySelectorAll('#uibody tr')];
+ const cur=rows.find(r=>r.dataset.face==='cursor');
+ A(!!cur,'cursor row present');
+ A(!!cur.cells[2].querySelector('.cdd'),'cursor keeps the fg swatch');
+ A(!!cur.cells[3].querySelector('.cdd'),'cursor keeps the bg swatch');
+ A(!cur.cells[4].querySelector('.enumdd')&&cur.cells[4].textContent.includes('—'),'cursor mutes the style controls');
+ A(cur.cells[5].textContent.includes('—'),'cursor mutes the box control');
+ const ml=rows.find(r=>r.dataset.face==='mode-line');
+ A(!!ml.cells[4].querySelector('.enumdd'),'non-cursor rows keep the style controls');
+});
// Palette-generator gate (open with #generatortest): previewing is non-mutating,
// clicking a generated tile loads the existing selector, adding creates a normal
// singleton base column, and appending a preview column commits all span members
@@ -937,7 +952,7 @@ if(location.hash==='#pickertest')gate('pickertest',A=>{
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
if(location.hash==='#boxtest')gate('boxtest',A=>{
- LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
+ LOCKED.clear();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor');const saveBox=UIMAP[f].box; // cursor has no box control by design
UIMAP[f].box=null;buildUITable();
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
A(!!cell.querySelector('.boxcluster'),'box-cluster-present');
@@ -956,7 +971,7 @@ if(location.hash==='#boxtest')gate('boxtest',A=>{
// Style-cluster gate (open with #styletest): the style cell holds a weight
// selector, a slant selector, and box-like underline and strike controls.
if(location.hash==='#styletest')gate('styletest',A=>{
- buildUITable();const f=UI_FACES[0][0];
+ buildUITable();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor'); // cursor row has no style cluster by design
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
A(!!cluster,'style-cluster-present');
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index a9fe41db0..7f5727cef 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -3318,16 +3318,23 @@ function buildUITable(){
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.title=composeHoverTitle(FACE_DOCS[face],c0.title);c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.title='flash this face in the live preview';c0lbl.onclick=()=>flashUiPreview(face);c0.appendChild(c0lbl);
+ // Emacs draws the cursor as a rectangle: its fg colors the glyph sitting on
+ // it and its bg is the cursor color, but weight/slant/underline/strike and
+ // box are no-ops on it. Show only fg+bg for the cursor row; mute the rest.
+ const cursorOnly=(face==='cursor');
+ const naCell=t=>{const s=document.createElement('span');s.textContent='—';s.style.opacity='0.4';s.title=t;return s;};
const fgSel=uiSelect(face,'fg'),bgSel=uiSelect(face,'bg');
const cF=document.createElement('td');cF.appendChild(fgSel);
const cB=document.createElement('td');cB.appendChild(bgSel);
const cS=document.createElement('td');
- const stCtls=mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
- const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);
+ const stCtls=cursorOnly?[]:mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
+ if(cursorOnly){cS.appendChild(naCell('Emacs ignores weight/slant/underline/strike on the cursor face'));}
+ else{const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);}
const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt';
const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px';
- const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});cX.appendChild(boxCtl);
- const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
+ const cX=document.createElement('td');const boxCtl=cursorOnly?null:mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});
+ if(cursorOnly){cX.appendChild(naCell('Emacs ignores the box attribute on the cursor face'));}else{cX.appendChild(boxCtl);}
+ const cL=mkLockCell('ui:'+face,cursorOnly?[fgSel,bgSel,...exp.locks]:[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
tr.appendChild(cL);tr.appendChild(c0);tr.appendChild(cF);tr.appendChild(cB);tr.appendChild(cS);tr.appendChild(cX);tr.appendChild(cC);tr.appendChild(cP);tb.appendChild(tr);tb.appendChild(exp.detail);paintUI(face);
}
applyTableSort('uibody');
@@ -3580,6 +3587,21 @@ if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMA
pickEnum(pkgWeight(),'heavy');
A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited');
}));
+// Cursor-row gate (open with #cursorrowtest): the cursor face honors only fg
+// (the glyph on it) and bg (the cursor color); weight/slant/underline/strike and
+// box are no-ops, so the row mutes them to a dash while non-cursor rows keep them.
+if(location.hash==='#cursorrowtest')gate('cursorrowtest',A=>{
+ buildUITable();
+ const rows=[...document.querySelectorAll('#uibody tr')];
+ const cur=rows.find(r=>r.dataset.face==='cursor');
+ A(!!cur,'cursor row present');
+ A(!!cur.cells[2].querySelector('.cdd'),'cursor keeps the fg swatch');
+ A(!!cur.cells[3].querySelector('.cdd'),'cursor keeps the bg swatch');
+ A(!cur.cells[4].querySelector('.enumdd')&&cur.cells[4].textContent.includes('—'),'cursor mutes the style controls');
+ A(cur.cells[5].textContent.includes('—'),'cursor mutes the box control');
+ const ml=rows.find(r=>r.dataset.face==='mode-line');
+ A(!!ml.cells[4].querySelector('.enumdd'),'non-cursor rows keep the style controls');
+});
// Palette-generator gate (open with #generatortest): previewing is non-mutating,
// clicking a generated tile loads the existing selector, adding creates a normal
// singleton base column, and appending a preview column commits all span members
@@ -4296,7 +4318,7 @@ if(location.hash==='#pickertest')gate('pickertest',A=>{
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
if(location.hash==='#boxtest')gate('boxtest',A=>{
- LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
+ LOCKED.clear();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor');const saveBox=UIMAP[f].box; // cursor has no box control by design
UIMAP[f].box=null;buildUITable();
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
A(!!cell.querySelector('.boxcluster'),'box-cluster-present');
@@ -4315,7 +4337,7 @@ if(location.hash==='#boxtest')gate('boxtest',A=>{
// Style-cluster gate (open with #styletest): the style cell holds a weight
// selector, a slant selector, and box-like underline and strike controls.
if(location.hash==='#styletest')gate('styletest',A=>{
- buildUITable();const f=UI_FACES[0][0];
+ buildUITable();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor'); // cursor row has no style cluster by design
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
A(!!cluster,'style-cluster-present');