diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/google-keep/keep-bridge.py | 92 | ||||
| -rw-r--r-- | scripts/google-keep/test_keep_bridge.py | 152 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 15 | ||||
| -rw-r--r-- | scripts/theme-studio/browser-gates.js | 19 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 34 |
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'); |
