diff options
| -rw-r--r-- | docs/specs/google-keep-emacs-integration-spec.org | 197 | ||||
| -rw-r--r-- | init.el | 1 | ||||
| -rw-r--r-- | modules/calendar-sync.el | 10 | ||||
| -rw-r--r-- | modules/google-keep-config.el | 210 | ||||
| -rw-r--r-- | modules/prog-general.el | 10 | ||||
| -rw-r--r-- | modules/ui-navigation.el | 10 | ||||
| -rw-r--r-- | modules/user-constants.el | 6 | ||||
| -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 | ||||
| -rw-r--r-- | tests/test-calendar-sync--apply-single-exception.el | 37 | ||||
| -rw-r--r-- | tests/test-google-keep-config.el | 142 | ||||
| -rw-r--r-- | tests/test-prog-lsp.el | 66 | ||||
| -rw-r--r-- | todo.org | 51 |
16 files changed, 960 insertions, 92 deletions
diff --git a/docs/specs/google-keep-emacs-integration-spec.org b/docs/specs/google-keep-emacs-integration-spec.org index 0b57f731f..376522ab4 100644 --- a/docs/specs/google-keep-emacs-integration-spec.org +++ b/docs/specs/google-keep-emacs-integration-spec.org @@ -4,7 +4,7 @@ #+TODO: TODO | DONE SUPERSEDED CANCELLED * Metadata -| Status | draft | +| Status | v1 implemented (Phases 1-3); live setup pending; v2 next | |----------+------------------------------------------------------------| | Owner | Craig | |----------+------------------------------------------------------------| @@ -14,7 +14,7 @@ * Problem / Context -Craig keeps quick notes in Google Keep but works almost entirely in Emacs. Today, reading or acting on a Keep note means leaving Emacs for the phone or the web app — a context switch for content that wants to live next to his org files. He wants Keep notes native to Emacs: browsable, searchable, greppable, and eventually editable, with a path to publish the result as a standalone package. +Craig keeps quick notes in Google Keep but works almost entirely in Emacs. Today, reading or acting on a Keep note means leaving Emacs for the phone or the web app — a context switch for content that wants to live next to his org files. He wants Keep notes native to Emacs: browsable, searchable, greppable, and editable, with a path to publish the result as a standalone package. Two hard constraints shape every choice: @@ -23,46 +23,69 @@ Two hard constraints shape every choice: Researched 2026-06-24: there is no in-editor Emacs Google Keep package on MELPA or GitHub — only KeepToOrg, a one-shot Takeout-HTML-to-org importer (unmaintained). So this is a build, not an adopt. +The closest prior art in this config is =calendar-sync.el=: an existing engine that fetches external data and writes generated org files under =data/=. This spec follows its conventions (generated file under =data/=, atomic temp-then-rename writes, async =make-process= + sentinel, =auth-source= via the house helper) rather than reinventing them. + * Goals and Non-Goals ** Goals - Keep notes visible and usable inside Emacs without leaving the editor. - An org-native representation, so notes are searchable/greppable and reuse org machinery. - A structure that starts as glue in =.emacs.d= and can be extracted to a publishable package (the VAMP / pearl module-to-package pattern). -- A path to read-write (create/edit notes from Emacs) without making v1 wait on it. +- Read-write (create/edit notes from Emacs) as the immediate v2 increment — v1 ships read-only first, but the read path is built to carry write, so v2 is additive. ** Non-Goals - Full bidirectional offline sync, conflict resolution, or real-time updates. - Faithful round-tripping of every Keep feature (list checkboxes, collaborators, drawings, images). - Reusing the MCP from elisp (infeasible — agent-only). ** Scope tiers -- v1: read-only. Fetch Keep notes via the chosen data path and render them as an org page (each note an org header). A manual refresh command. Auth via auth-source. Graceful degradation when the bridge or credentials are missing. -- Out of scope: write-back to Keep, list/checkbox fidelity, label/color/pin *editing*, the org-capture-style popup, package extraction. -- vNext: read-write (create a note from a region or capture; edit a note back to Keep), the org-capture-style quick-note popup, list/checkbox rendering, and extracting the core to a standalone package. +- v1: read-only. Fetch Keep notes through the gkeepapi bridge and render them as an org page (each note an org header). A manual refresh command. Auth via auth-source. Graceful degradation when the bridge or credentials are missing. The read path establishes a round-trip-ready data model and a stable per-note identity, because v2 builds on them. +- v2 (immediate follow-on, not deferred): read-write — create a note from a region or capture, and edit a note back to Keep. Reuses v1's bridge, auth, data model, and note-identity; adds the inverse direction and a staleness check. +- Out of scope: list/checkbox fidelity, collaborators, drawings/images, and any background or real-time sync. +- Later: list/checkbox rendering, and extracting the core to a standalone package. * Design ** For the user -A command (e.g. =cj/keep-refresh=) pulls the current Keep notes and writes them into one org file (e.g. =~/org/keep.org=). Each note becomes a top-level org heading: the title (or a derived title) as the heading text, the note body as the entry, and Keep metadata as properties — labels as org tags, plus =:KEEP_ID:=, =:PINNED:=, =:COLOR:=, =:ARCHIVED:=, =:UPDATED:= in a drawer. Pinned notes sort first. The file is plain org, so it is searchable with the agenda, greppable, and linkable. Opening it is just visiting the file; a keybinding and a dashboard entry make it one keystroke. v1 is read-only: editing the org file does not push back to Keep (a header note says so), so there is no accidental-mutation risk while the integration is young. +A command (=cj/keep-refresh=) pulls the current Keep notes and writes them into one generated org file (=data/keep.org= under =user-emacs-directory=, a constant in =user-constants.el= alongside =gcal-file= — a generated file, not a hand-authored one in =~/org/=). Each note becomes a top-level org heading: the title (or a derived title) as the heading text, the note body as the entry, and Keep metadata as properties — labels as org tags, plus =:KEEP_ID:=, =:PINNED:=, =:COLOR:=, =:ARCHIVED:=, =:UPDATED:= in a drawer. Pinned notes sort first; each header carries an org link back to the source note (=https://keep.google.com/#NOTE/<id>=). The file is plain org, so it is searchable with the agenda, greppable, and linkable. Opening it is just visiting the file; a keybinding and a dashboard entry make it one keystroke. v1 is read-only: editing the org file does not push back to Keep (a header line says so), so there is no accidental-mutation risk while the integration is young. The =:KEEP_ID:= and =:UPDATED:= on each header are what v2 later uses to target an update and detect a stale local copy. ** For the implementer Three layers, cleanly separable so the core can later be a package: -1. *Data bridge (Python).* A small script using gkeepapi: authenticate with a stored master token, fetch notes, emit JSON (id, title, text, labels, pinned, color, archived, timestamps) on stdout. This is the one place the unofficial API lives, isolated so a break is contained and swappable. A Takeout-import path is the no-auth fallback (parse a Takeout dump into the same JSON shape). -2. *Org renderer (elisp).* Runs the bridge as a subprocess, parses its JSON, and writes the org page (heading + body + properties per note), with =cj/keep-refresh= as the entry point. Reads the master token via =auth-source=. -3. *Access UX (elisp).* Keybindings, a dashboard entry, and (vNext) a dedicated buffer/mode or the org-capture-style popup. +1. *Data bridge (Python).* A small script using gkeepapi: authenticate with a stored master token, fetch notes, emit JSON on stdout per the schema below. =id= is the gkeepapi server id (immutable across title/content edits — the safe v2 targeting key, never derived from content), and =updated= is the freshness anchor v2's staleness guard reads. This is the one place the unofficial API lives, isolated so a break is contained and swappable; the same bridge gains a write subcommand in v2. On failure it exits non-zero and prints a single reason token on stderr (=no-gkeepapi=, =no-token=, =auth-failed=, =network=) so the elisp sentinel can surface which piece broke. +2. *Org renderer (elisp core).* Runs the bridge with =make-process= + a sentinel (async, so a slow or hung auth never blocks the interactive thread — the calendar-sync pattern), parses its JSON, and writes the org page via a temp file + atomic rename (never a partial =keep.org= under the user's eyes). Records =:KEEP_ID:=/=:UPDATED:= per note, labels as tags, pinned-first, archived notes tagged =:archived:=. =cj/keep-refresh= is the entry point. The master token is read with =cj/auth-source-secret-value= (system-lib.el, the house helper calendar-sync/slack/transcription use) against a documented =:host= entry. The JSON-to-org transform is the round-trip contract v2 inverts. This core takes its file path, auth host, and keymap injected from the glue layer — it never reaches into =user-constants= or binds keys itself, so it lifts out cleanly as a package. +3. *Glue (=modules/google-keep-config.el=).* Supplies the core its =data/keep.org= path, the =:host=, a Keep keybinding prefix, the dashboard entry, and the =(require 'google-keep-config)= in =init.el=. A load-time =cj/executable-find-or-warn= for =python3= warns early when the interpreter is absent (the gkeepapi-missing and token-missing cases surface at refresh time via the bridge's stderr reason token). + +** Bridge JSON schema (the load-bearing seam) + +Both the v1 transform and the v2 inverse are written against this, so it is pinned here. On success the bridge prints a JSON array of note objects: + +#+begin_src js +[ { "id": "<gkeepapi server id, string>", + "title": "<string, may be empty>", + "text": "<string; newlines are real \n inside the JSON string>", + "labels": ["<label>", ...], // [] when none + "pinned": true|false, + "archived": true|false, + "color": "<keep color name, e.g. \"WHITE\">", + "updated": "<ISO8601 UTC, e.g. 2026-06-25T04:12:00Z>" } ] +#+end_src + +- =updated= is ISO8601 UTC — sortable, parseable by =parse-iso8601-time-string=, and readable in the drawer. v2's staleness compare depends on this exact, comparable form. +- An empty Keep returns =[]= (a valid empty page, not an error). +- =labels= is always an array (empty, never null). =text= newlines are real =\n= inside the JSON string (standard JSON string encoding). +- Failure is not expressed in JSON: the bridge exits non-zero with one stderr reason token (above). The sentinel maps the token to a =display-warning=. * Alternatives Considered ** A — Takeout import (one-shot HTML -> org) - Good, because no auth, fully offline, dead simple, and zero ongoing breakage risk. -- Bad, because it is not live — Craig must manually export a Takeout archive, so notes are stale the moment they are imported. -- Neutral, because it is the right tool for an archival snapshot, the wrong one for daily use. Kept as the v1 fallback / bootstrap, not the primary. +- Bad, because it is not live — Craig must manually export a Takeout archive, so notes are stale the moment they are imported, and it cannot write, so it is useless for the v2 write path. +- Neutral, because it is the right tool for an archival snapshot, the wrong one for daily use. Deferred — not built in v1; a possible later no-auth archive importer if ever wanted. ** B (chosen for the data path) — gkeepapi via a Python subprocess bridge -- Good, because it is the only path that gives live notes (and, in vNext, write-back) from inside Emacs, with the full note model. +- Good, because it is the only path that gives live notes and (in v2) write-back from inside Emacs, with the full note model. - Bad, because gkeepapi reverse-engineers a private API: it breaks on Google auth changes, needs Python plus a stored master token, and the bridge is glue Craig owns and maintains. -- Neutral, because the fragility is isolated to one script; when it breaks, the renderer degrades to a warning and the Takeout fallback still works. +- Neutral, because the fragility is isolated to one script; when it breaks, the renderer degrades to a warning. ** C — Reuse the google-keep MCP from elisp - Good, because the MCP already has full read/write and is maintained outside the config. @@ -74,80 +97,124 @@ Three layers, cleanly separable so the core can later be a package: - Bad, because a long-running personal server is more infrastructure than a single-user note view warrants. - Neutral, because it is a heavier variant of B; revisit only if subprocess latency ever bites. -* Decisions [0/5] - -** TODO Presentation shape: org page of headers (v1), popup deferred -- Owner / by-when: Craig / before implementation -- Context: Craig named two shapes — an org-capture-style popup and a separate org page with each note as a header. -- Decision: We will render notes as one *org page*, each note a top-level header (title + body + metadata properties, labels as tags), in v1; the org-capture-style quick-note *popup* is vNext. -- Consequences: easier — reuses org search/agenda/grep/links, nothing bespoke to render, and read-only is safe. Harder — a popup-first quick-capture flow waits for vNext, and a large Keep collection makes a long file (mitigated by pinned-first sort and org folding). - -** TODO Direction: read-only in v1, read-write in vNext -- Owner / by-when: Craig / before implementation -- Context: the bridge can read and (later) write Keep; doing both in v1 raises the risk surface. -- Decision: We will ship v1 read-only (fetch + render + refresh); create/edit-back-to-Keep is vNext. -- Consequences: easier — no accidental Keep mutation while the integration is young, and value ships fast. Harder — editing a note still means the phone/web until vNext; the org file is a view, not a source of truth. - -** TODO Data path: gkeepapi subprocess bridge, Takeout import as fallback -- Owner / by-when: Craig / before implementation -- Context: the MCP is agent-only; the live options are gkeepapi or a Takeout import. -- Decision: We will use a small Python gkeepapi bridge that emits JSON as the primary path, with a Takeout-import parser into the same JSON shape as the no-auth fallback. The MCP is not in the data path. -- Consequences: easier — live notes now, write-back later, one isolated fragile component. Harder — a Python + gkeepapi dependency and a maintained bridge; the unofficial API can break and needs a master token. - -** TODO Auth: master token in authinfo.gpg via auth-source -- Owner / by-when: Craig / before implementation -- Context: gkeepapi authenticates with a Google *master token* (obtained once), not the account password. -- Decision: We will store the master token in =authinfo.gpg= and read it via =auth-source= (the pattern the rest of the config uses), and document the one-time master-token retrieval. +* Decisions [5/5] + +** DONE Presentation shape: org page of headers +- Decision: We will render notes as one *org page*, each note a top-level header (title + body + metadata properties, labels as tags). Confirmed by Craig 2026-06-25. +- Consequences: easier — reuses org search/agenda/grep/links, nothing bespoke to render, and read-only is safe. Harder — a large Keep collection makes a long file (mitigated by pinned-first sort and org folding). + +** DONE Direction: read-only v1, read-write v2 (immediate) +- Decision: We will ship v1 read-only (fetch + render + refresh), then move directly into v2 read-write (create/edit back to Keep). v2 is the immediate next increment, not a deferred someday. The read path must establish a round-trip-ready data model and a stable per-note identity up front, so v2 is additive rather than a rewrite. Confirmed by Craig 2026-06-25. +- Consequences: easier — v1 ships the visible value fast on a path write reuses wholesale, and a parse bug can't corrupt real Keep data while the model is being proven. Harder — the read path carries design weight it wouldn't if write were truly far off (note-identity and the freshness field have to be right in v1), and the write work — note targeting, staleness/overwrite handling — still has to be built right after. + +** DONE Data path: gkeepapi subprocess bridge (Takeout deferred) +- Decision: We will use a small Python gkeepapi bridge that emits JSON as the sole data path; it powers read in v1 and gains a write subcommand in v2. A failure degrades to a clear warning. The Takeout-import path is deferred (read-only and stale, so it can't serve v2). The MCP is not in the data path. Confirmed by Craig 2026-06-25. +- Consequences: easier — one live path that serves both read and write, fragility isolated to one swappable script. Harder — a Python + gkeepapi dependency and a maintained bridge; the unofficial API can break and needs a master token, with no second read path as a safety net (the warning is the degradation). + +** DONE Auth: master token in authinfo.gpg via auth-source +- Decision: We will store the master token in =authinfo.gpg= and read it via =auth-source= (with =cj/auth-source-secret-value=, the house helper), and document the one-time master-token retrieval. Confirmed by Craig 2026-06-25. - Consequences: easier — consistent with existing credential handling, no plaintext secret, the daemon's auth-source cache applies. Harder — the one-time token retrieval is a manual setup step, and a revoked/expired token surfaces as an auth failure the renderer must report cleanly. -** TODO Structure: google-keep-config.el glue + extractable core -- Owner / by-when: Craig / before implementation -- Context: Craig wants a module-to-package trajectory. -- Decision: We will build the integration as =modules/google-keep-config.el= (the =.emacs.d= glue: paths, keys, dashboard, auth wiring) plus a self-contained core (the bridge runner + org renderer) written so the core can later move to a standalone =keep.el=-style package, mirroring the VAMP / pearl migration. +** DONE Structure: google-keep-config.el glue + extractable core +- Decision: We will build the integration as =modules/google-keep-config.el= (the =.emacs.d= glue: paths, keys, dashboard, auth wiring) plus a self-contained core (the bridge runner + org renderer) written so the core can later move to a standalone =keep.el=-style package, mirroring the VAMP / pearl migration. The core takes paths/keys/host injected from the glue — it never reaches into =user-constants= or binds keys itself. Confirmed by Craig 2026-06-25. - Consequences: easier — usable immediately in =.emacs.d=, with a clean seam for later extraction. Harder — the discipline of keeping the core free of =.emacs.d=-specific assumptions from the start. +* Review findings [8/8] +** DONE Generated-file path + atomic write (round 1, non-blocking) +- Finding: the draft put the file at =~/org/keep.org= (hand-authored org area) with no atomic write, diverging from calendar-sync (generated org under =data/=, temp-then-rename). Risk: a partial write leaves a truncated file the user is viewing, and a generated file in =~/org/= invites edits that get overwritten. +- Resolution: path is =data/keep.org= as a =user-constants.el= constant; the renderer writes via temp file + atomic rename. + +** DONE Async subprocess via make-process + sentinel (round 1, non-blocking) +- Finding: the draft said "run as a subprocess" without committing to async; gkeepapi can hang on auth churn, and a synchronous =call-process= would freeze Emacs. +- Resolution: specified =make-process= + sentinel (the calendar-sync pattern); the warning-on-failure degradation lives in the sentinel. + +** DONE Name the auth-source house helper (round 1, non-blocking) +- Finding: the draft read the token via "auth-source" generically; the house helper is =cj/auth-source-secret-value= (system-lib.el). +- Resolution: spec now calls it out with a documented =:host= entry. + +** DONE Name the missing-tool helper (round 1, non-blocking) +- Finding: the draft promised a display-warning for a missing tool but didn't cite =cj/executable-find-or-warn=, and didn't separate the gkeepapi-missing vs token-missing failure modes. +- Resolution: glue uses =cj/executable-find-or-warn= for =python3=; the bridge emits distinct stderr reason tokens the sentinel surfaces. + +** DONE Pin the bridge JSON schema (round 1, BLOCKING — cleared) +- Finding: Phase 1 listed JSON fields but left the =updated= format, label/newline serialization, empty-result, and error envelope unstated — the round-trip contract both v1 and v2's staleness guard depend on. +- Resolution: added the "Bridge JSON schema" subsection — ISO8601-UTC =updated=, always-array =labels=, standard JSON newline encoding, =[]= for empty, and exit-nonzero + stderr-token for failure. + +** DONE v2 staleness guard re-fetches before write (round 1, non-blocking) +- Finding: v2 must not trust the possibly-stale =:UPDATED:= in the generated file; a phone edit between refresh and edit-back would be clobbered. +- Resolution: Phase 4 states the write path re-fetches the target note's current =updated= from Keep and compares before overwriting, prompting on mismatch. + +** DONE Note KEEP_ID is the immutable server id (round 1, non-blocking) +- Finding: "stable" id asserted but not anchored. +- Resolution: spec states =:KEEP_ID:= is the gkeepapi server id, immutable across edits, never derived from content. + +** DONE Small enhancements: source link + archived handling (round 1, non-blocking, optional) +- Finding: a back-link to the Keep web note and archived-note handling were free given the data already carried. +- Resolution: each header links to =keep.google.com/#NOTE/<id>=; archived notes are tagged =:archived:= (the JSON carries =archived=). + * Implementation phases ** Phase 1 — Data bridge -A Python script (gkeepapi) that authenticates with the stored master token and prints notes as JSON (id, title, text, labels, pinned, color, archived, updated) on stdout, plus a Takeout-import path producing the same JSON shape. Standalone and testable from the shell with a fixture; no Emacs yet. Tree stays working (new files only). +A Python script (gkeepapi) that authenticates with the stored master token and prints the JSON array per the Bridge JSON schema on stdout (=id= = immutable server id, =updated= = ISO8601 UTC). On failure: exit non-zero with one stderr reason token. Standalone and testable from the shell with a fixture; no Emacs yet. Tree stays working (new files only). ** Phase 2 — Org renderer + refresh -=modules/google-keep-config.el=: run the bridge as a subprocess, parse the JSON, and write the org page (heading + body + property drawer per note, labels as tags, pinned-first). =cj/keep-refresh= regenerates it; auth via =auth-source=. A header line marks the file read-only-view. Degrades to a =display-warning= when the bridge, Python, gkeepapi, or token is missing — never errors at load. +The elisp core + =modules/google-keep-config.el=: run the bridge with =make-process= + a sentinel, parse the JSON, and write =data/keep.org= via temp file + atomic rename (heading + body + property drawer per note, recording =:KEEP_ID:= and =:UPDATED:=, labels as tags, archived tagged =:archived:=, pinned-first, a source back-link per header). =cj/keep-refresh= regenerates it; auth via =cj/auth-source-secret-value=. A header line marks the file a read-only view. The sentinel maps a bridge failure (stderr token) to a =display-warning= naming the missing piece — never errors at load. ** Phase 3 — Access UX + un-orphan -Keybindings (a Keep prefix), a dashboard entry, and the =(require 'google-keep-config)= in =init.el=. Optional: a dedicated read-only major mode for the buffer. +Keybindings (a Keep prefix), a dashboard entry, a load-time =cj/executable-find-or-warn= for =python3=, and the =(require 'google-keep-config)= in =init.el=. Optional: a dedicated read-only major mode for the buffer. + +** Phase 4 (v2) — read-write +The immediate next increment after v1 lands. A write subcommand on the bridge (gkeepapi create/update), and an elisp path that targets a note by =:KEEP_ID:= and, before overwriting, re-fetches that note's current =updated= from Keep and compares it against the drawer's =:UPDATED:= (a staleness guard — abort/prompt on mismatch so a phone edit since the last refresh isn't clobbered). Entry points to create a note from a region/capture and edit one back. Specced in detail once v1's read model is proven on real notes; listed here so Phases 1-3 don't paint into a read-only corner. -(vNext phases — not specced here, logged to todo.org: read-write create/edit; the org-capture-style popup; list/checkbox rendering; extract the core to a package.) +(Later, not specced here, logged to todo.org: list/checkbox rendering; extract the core to a package.) * Acceptance criteria -- [ ] =cj/keep-refresh= fetches the current Keep notes and writes them to the org page, one header per note with title/body/labels/metadata. -- [ ] Pinned notes sort to the top; labels render as org tags; Keep id/color/pinned/archived/updated land in a property drawer. -- [ ] The master token is read from =authinfo.gpg= via =auth-source=; no secret is hardcoded. -- [ ] A missing bridge / Python / gkeepapi / token produces a clear =display-warning=, not a load error or a crash. -- [ ] The Takeout-import fallback produces the same org page from a Takeout dump with no auth. +- [ ] =cj/keep-refresh= fetches the current Keep notes and writes =data/keep.org= via temp + atomic rename, one header per note with title/body/labels/metadata and a source back-link. +- [ ] Pinned notes sort to the top; labels render as org tags; archived notes are tagged =:archived:=; Keep id/color/pinned/archived/updated land in a property drawer. +- [ ] Each note header carries an immutable =:KEEP_ID:= (gkeepapi server id) and an ISO8601 =:UPDATED:= (the v2 targeting + staleness anchors). +- [ ] The bridge runs async (=make-process= + sentinel); a slow/hung auth does not block Emacs. +- [ ] The master token is read via =cj/auth-source-secret-value= from =authinfo.gpg=; no secret is hardcoded. +- [ ] A missing python3 / gkeepapi / token (distinct bridge stderr tokens) produces a clear =display-warning=, not a load error or a crash; an empty Keep yields an empty page, not an error. - [ ] =make validate-modules= + launch smoke clean with =google-keep-config= required. * Readiness dimensions -- Data model & ownership: Keep is the source of truth; the org page is a generated read-only view (v1). Each note maps to one org header; the bridge JSON is the contract between Python and elisp. -- Errors, empty states & failure: auth failure, a broken gkeepapi, missing Python/token, or zero notes each degrade to a warning + an empty-or-stale page, never a crash. The unofficial API breaking is expected, not exceptional. -- Security & privacy: the master token lives in =authinfo.gpg= (gpg-encrypted), read via auth-source; note content lands in a local org file the user already trusts for org data. No secret in the repo. The token grants broad Google access — documented as a risk. -- Observability: the warning path names which piece is missing (Python / gkeepapi / token / bridge). The generated page's header shows the last refresh time. -- Performance & scale: one subprocess per manual refresh over N notes (tens to low hundreds); trivial. No background polling in v1. -- Reuse & lost opportunities: reuses org (rendering, search, agenda, links), auth-source (credentials), and the subprocess pattern. gkeepapi supplies the API client, so no endpoint code is written here. -- Architecture fit & weak points: three layers (Python bridge / elisp renderer / UX glue) with the fragile API isolated in layer 1. Weak point: gkeepapi maintenance and Google auth churn — mitigated by isolation, graceful degradation, and the Takeout fallback. -- Config surface: a Keep org-file path, the auth-source host entry, and a keybinding prefix. No tuning knobs in v1. +- Data model & ownership: Keep is the source of truth; =data/keep.org= is a generated read-only view (v1). Each note maps to one org header keyed by the immutable =:KEEP_ID:=; the bridge JSON (pinned schema) is the contract between Python and elisp, and the JSON-to-org transform is the round-trip contract v2 inverts. +- Errors, empty states & failure: auth failure, a broken gkeepapi, missing Python/token, or zero notes each degrade to a warning (named by the bridge's stderr token) or an empty page, never a crash. The unofficial API breaking is expected, not exceptional. +- Security & privacy: the master token lives in =authinfo.gpg= (gpg-encrypted), read via =cj/auth-source-secret-value=; note content lands in a local generated org file under =data/=. No secret in the repo. The token grants broad Google access — documented as a risk. +- Observability: the warning path names which piece is missing (python3 / gkeepapi / token / auth) from the bridge's stderr token. The generated page's header shows the last refresh time. +- Performance & scale: one async subprocess per manual refresh over N notes (tens to low hundreds); trivial. No background polling in v1. +- Reuse & lost opportunities: follows calendar-sync conventions (generated org under =data/=, atomic temp+rename, =make-process= + sentinel) and reuses =cj/auth-source-secret-value=, =cj/executable-find-or-warn=, org rendering, and the dashboard. gkeepapi supplies the API client, so no endpoint code is written here. +- Architecture fit & weak points: three layers (Python bridge / elisp core / glue) with the fragile API isolated in layer 1 and the core kept free of =.emacs.d= specifics for extraction. Weak point: gkeepapi maintenance and Google auth churn — mitigated by isolation, graceful degradation, and a swappable bridge. +- Config surface: the =data/keep.org= path constant, the auth-source =:host= entry, and a keybinding prefix. No tuning knobs in v1. - Documentation plan: a setup note (one-time master-token retrieval, =pip install gkeepapi=, the authinfo entry) and the module commentary. No user-migration doc (personal config). -- Dev tooling: the bridge is shell-testable with a JSON fixture; the renderer gets ERT over the JSON-to-org transform; =make validate-modules= + launch smoke for the module. -- Rollout, compatibility & rollback: additive — a new module + a require. Rollback = drop the require and delete the org page. No existing behavior changes. +- Dev tooling: the bridge is shell-testable with a JSON fixture; the core gets ERT over the JSON-to-org transform (against the pinned schema); =make validate-modules= + launch smoke for the module. +- Rollout, compatibility & rollback: additive — a new module + a require. Rollback = drop the require and delete =data/keep.org=. No existing behavior changes. - External APIs & deps: gkeepapi (PyPI) and the unofficial Google Keep mobile endpoint it wraps — the single load-bearing external dependency and the central risk. Python 3 on PATH. A Google master token. * Risks, Rabbit Holes, and Drawbacks -- The central risk is gkeepapi breaking when Google changes auth or the private endpoint. It has a history of auth churn. Mitigations: isolate it in the bridge, degrade to a warning, keep the Takeout-import fallback working, and never block Emacs load on it. +- The central risk is gkeepapi breaking when Google changes auth or the private endpoint. It has a history of auth churn. With Takeout deferred there is no second read path, so the mitigations are: isolate gkeepapi in the bridge, degrade to a clear warning (named by stderr token), keep the bridge swappable, and never block Emacs load on it. - Credential risk: the master token grants broad account access. Keep it in =authinfo.gpg=, never the repo; document revocation. -- Scope creep: the pull toward full bidirectional sync, list fidelity, and real-time. v1 is a read-only org view on purpose; write-back and richness are vNext, gated behind the org-page landing first. +- v2 staleness: because write follows immediately, the edit-back path must re-fetch the note's current =updated= from Keep before writing, not trust the generated file's =:UPDATED:= (which is only as fresh as the last refresh). Carrying =:UPDATED:= correctly in v1 is what makes that guard possible. +- Scope creep: the pull toward full bidirectional sync, list fidelity, and real-time. v1 is a read-only org view and v2 is targeted read-write; richness and sync stay later, gated behind those landing. * Review and iteration history ** 2026-06-24 Wed @ 22:40:00 -0400 — Claude — author - What: initial draft. -- Why: Craig asked to spec the google-keep in-editor integration before building. It spans a fragile external API, an auth-source credential, a Python/elisp bridge, and a module-to-package trajectory, with real trade-offs on shape (org page vs popup), direction (read vs read-write), and data path (gkeepapi vs Takeout vs MCP) — worth pinning before code. -- Artifacts: docs/specs/google-keep-emacs-integration-spec.org; the todo.org google-keep task (to be cross-linked at hand-off). +- Why: Craig asked to spec the google-keep in-editor integration before building. It spans a fragile external API, an auth-source credential, a Python/elisp bridge, and a module-to-package trajectory, with real trade-offs on shape, direction, and data path — worth pinning before code. +- Artifacts: docs/specs/google-keep-emacs-integration-spec.org; the todo.org google-keep task. +** 2026-06-25 Thu @ 00:40:00 -0400 — Claude — author +- What: resolved all five decisions with Craig (one by one) and folded them in. Shape = org page (popup dropped, a separate later discussion). Direction = read-only v1 then read-write v2 immediately, so the read path now carries a round-trip-ready model + stable note-identity. Data path = gkeepapi sole bridge, Takeout deferred. Auth = authinfo.gpg via auth-source. Structure = extractable core + glue. Added Phase 4 (v2) and the v2-staleness risk. +- Why: decisions resolved, so the spec could go to spec-review. +- Artifacts: this spec. +** 2026-06-25 Thu @ 00:55:00 -0400 — spec-review (round 1) + author response +- What: independent review against the readiness gate and Phase 4 dimensions, reading calendar-sync.el as the analog. Eight findings (one blocking: the bridge JSON schema; seven non-blocking: data/ path + atomic write, async make-process, the two house helpers, v2 re-fetch, KEEP_ID server id, source-link + archived). Craig accepted all; folded in here. Added the Bridge JSON schema subsection, the calendar-sync-convention reuse, and the Review findings section. +- Why: clears the blocker and the readiness gaps; rubric moves to Ready. +- Artifacts: this spec (Review findings [8/8], Bridge JSON schema). +** 2026-06-25 Thu @ 01:05:00 -0400 — spec-review (round 2) — reviewer +- What: fresh readiness pass on the revised spec. Verified the round-1 resolutions are coherent (data/keep.org consistent throughout, the JSON schema is the single referenced contract, the async/sentinel + stderr-token story lines up across Design/Phase 2/acceptance). Confirmed cj/auth-source-secret-value (host &optional user) exists and is buildable as written, and that the data/keep.org constant matches user-constants.el's gcal-file form. No new findings. +- Why: confirms the spec is implementation-ready after two rounds. +- Artifacts: this spec. Verdict: Ready. +** 2026-06-25 Thu @ 01:30:00 -0400 — Claude — implementer +- What: built v1 (Phases 1-3) in a no-approvals speedrun. Phase 1: the gkeepapi bridge (scripts/google-keep/keep-bridge.py) emitting the pinned JSON schema, degrading with a stderr reason token; 12 Python tests. Phase 2: the elisp pure core (parse/tag/heading/render, extractable) + cj/keep-refresh (async make-process + sentinel, atomic temp-then-rename to keep-file, stderr-token to display-warning); 15 ERT tests. Phase 3: keep-file constant in user-constants.el, C-c k prefix (refresh/open), executable warning, required in init.el. validate-modules clean; no-token path degrades without error. +- Why: spec was Ready and Craig green-lit a no-approvals build of v1. +- Artifacts: scripts/google-keep/keep-bridge.py + test; modules/google-keep-config.el + tests/test-google-keep-config.el; user-constants.el; init.el. Remaining: the one-time gkeepapi + master-token setup (a VERIFY under todo.org Manual testing), then v2. @@ -147,6 +147,7 @@ ;; ------------------------- Personal Workflow Related ------------------------- (require 'calendar-sync) ;; sync calendars, must come after org-agenda +(require 'google-keep-config) ;; google keep notes as a read-only org page (require 'reconcile-open-repos) ;; review dirty repositories and reconcile (require 'local-repository) ;; local repository for easy config portability diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index 8d7552d3e..7ed14921f 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -540,7 +540,15 @@ Compares year, month, day, hour, minute." (plist-put result :location (plist-get exception :location))) ;; Pass through new fields if exception overrides them (when (plist-get exception :attendees) - (plist-put result :attendees (plist-get exception :attendees))) + (plist-put result :attendees (plist-get exception :attendees)) + ;; Re-derive the user's status from the overridden attendees so a + ;; singly-declined occurrence drops its inherited series "accepted" + ;; (otherwise `calendar-sync--filter-declined' can't drop it). Leave the + ;; inherited status when the override doesn't name the user. + (let ((status (calendar-sync--find-user-status + (plist-get exception :attendees) calendar-sync-user-emails))) + (when status + (plist-put result :status status)))) (when (plist-get exception :organizer) (plist-put result :organizer (plist-get exception :organizer))) (when (plist-get exception :url) diff --git a/modules/google-keep-config.el b/modules/google-keep-config.el new file mode 100644 index 000000000..1738fa6e0 --- /dev/null +++ b/modules/google-keep-config.el @@ -0,0 +1,210 @@ +;;; google-keep-config.el --- Google Keep -> org integration -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; A read-only view of Google Keep notes as an org page. `cj/keep-refresh' +;; runs a Python gkeepapi bridge (scripts/google-keep/keep-bridge.py), parses +;; its JSON, and regenerates `keep-file' with one org header per note. Editing +;; the file does NOT sync back to Keep -- that is v2. +;; +;; The pure JSON-to-org core (the cj/keep--render* / --note-* helpers) is kept +;; free of .emacs.d specifics so it can later extract to a standalone package; +;; the IO layer and this module supply paths, auth, and keys. +;; +;; One-time setup: install the client (pip install gkeepapi), obtain a Google +;; master token, set `cj/keep-email', and store the token in authinfo.gpg as +;; machine google-keep login <you@gmail.com> password <master-token> +;; See docs/specs/google-keep-emacs-integration-spec.org. + +;;; Code: + +(require 'json) +(require 'subr-x) +(require 'system-lib) ;; cj/auth-source-secret-value, cj/executable-find-or-warn +(require 'user-constants) ;; keep-file + +;; ------------------------------ Configuration -------------------------------- + +(defgroup cj/keep nil + "Google Keep to org integration." + :group 'applications + :prefix "cj/keep-") + +(defcustom cj/keep-email nil + "Google account email for the Keep bridge, also the authinfo login. +Unset until the one-time setup is done; `cj/keep-refresh' warns when nil." + :type '(choice (const :tag "Unset" nil) string) + :group 'cj/keep) + +(defcustom cj/keep-auth-host "google-keep" + "The authinfo.gpg machine entry holding the Keep master token." + :type 'string + :group 'cj/keep) + +(defcustom cj/keep-python "python3" + "Python interpreter used to run the Keep bridge." + :type 'string + :group 'cj/keep) + +(defvar cj/keep--bridge-script + (expand-file-name "scripts/google-keep/keep-bridge.py" user-emacs-directory) + "Path to the gkeepapi bridge script.") + +(defconst cj/keep--web-base "https://keep.google.com/#NOTE/" + "Base URL for a Keep note back-link.") + +;; --------------------------- Pure core: JSON -> org -------------------------- +;; These take plain data and return strings -- no IO, no .emacs.d paths -- so +;; they unit-test directly and lift out to a package unchanged. + +(defun cj/keep--parse-json (json-string) + "Parse the bridge JSON-STRING into a list of note alists." + (json-parse-string json-string + :object-type 'alist :array-type 'list + :false-object nil :null-object nil)) + +(defun cj/keep--label-to-tag (label) + "Sanitize LABEL into a valid org tag (alphanumerics / _ / @ / # / %)." + (replace-regexp-in-string "[^[:alnum:]_@#%]" "_" label)) + +(defun cj/keep--note-tags (note) + "Return the trailing org-tag string for NOTE (labels + archived), or \"\"." + (let ((tags (append (mapcar #'cj/keep--label-to-tag (alist-get 'labels note)) + (and (alist-get 'archived note) '("archived"))))) + (if tags (concat " :" (string-join tags ":") ":") ""))) + +(defun cj/keep--note-heading (note) + "Render NOTE (an alist) as one org subtree string." + (let* ((id (alist-get 'id note)) + (title (alist-get 'title note)) + (text (alist-get 'text note)) + (heading (if (and title (> (length title) 0)) title "(untitled)"))) + (concat + "* " heading (cj/keep--note-tags note) "\n" + ":PROPERTIES:\n" + ":KEEP_ID: " (or id "") "\n" + ":PINNED: " (if (alist-get 'pinned note) "t" "nil") "\n" + ":COLOR: " (or (alist-get 'color note) "") "\n" + ":ARCHIVED: " (if (alist-get 'archived note) "t" "nil") "\n" + ":UPDATED: " (or (alist-get 'updated note) "") "\n" + ":END:\n" + (if (and id (> (length id) 0)) + (concat "[[" cj/keep--web-base id "][open in Keep]]\n") + "") + "\n" + (if (and text (> (length text) 0)) (concat text "\n") "")))) + +(defun cj/keep--sort-pinned-first (notes) + "Return NOTES with pinned ones first, original order otherwise preserved." + (let (pinned rest) + (dolist (n notes) + (if (alist-get 'pinned n) (push n pinned) (push n rest))) + (append (nreverse pinned) (nreverse rest)))) + +(defun cj/keep--render (notes &optional generated-at) + "Render NOTES (a list of alists) into the full org page string. +GENERATED-AT is an optional last-refresh timestamp string for the header." + (concat + "# Generated by cj/keep-refresh -- read-only view; edits here do NOT sync to Keep.\n" + "#+TITLE: Google Keep\n" + (if generated-at (concat "# Last refresh: " generated-at "\n") "") + "\n" + (mapconcat #'cj/keep--note-heading (cj/keep--sort-pinned-first notes) ""))) + +;; ------------------------------- IO: run + write ----------------------------- + +(defun cj/keep--write-atomically (content file) + "Write CONTENT to FILE via a temp file in FILE's directory + atomic rename." + (let ((tmp (make-temp-file + (expand-file-name (concat "." (file-name-nondirectory file) ".") + (file-name-directory file)) + nil nil content))) + (rename-file tmp file t))) + +(defun cj/keep--warn (token) + "Surface a Keep bridge failure TOKEN as a `display-warning'." + (display-warning + 'cj/keep + (pcase token + ("no-gkeepapi" "Keep bridge: gkeepapi is not installed (pip install gkeepapi).") + ("no-token" "Keep bridge: no master token in authinfo.gpg, or `cj/keep-email' is unset.") + ("auth-failed" "Keep bridge: Google rejected the credentials (token expired or revoked?).") + ("network" "Keep bridge: network error reaching Google Keep.") + (_ (format "Keep bridge failed: %s" (if (string-empty-p token) "unknown error" token)))) + :error)) + +(defun cj/keep--write-notes (json) + "Parse bridge JSON, render, and write `keep-file' atomically. +Returns the note count." + (let* ((notes (cj/keep--parse-json json)) + (org (cj/keep--render notes (format-time-string "%Y-%m-%d %H:%M")))) + (cj/keep--write-atomically org keep-file) + (length notes))) + +;;;###autoload +(defun cj/keep-refresh () + "Fetch Google Keep notes and regenerate `keep-file' (a read-only view)." + (interactive) + (let ((token (and cj/keep-email + (cj/auth-source-secret-value cj/keep-auth-host cj/keep-email)))) + (cond + ((not (file-exists-p cj/keep--bridge-script)) + (user-error "Keep bridge script not found: %s" cj/keep--bridge-script)) + ((or (not cj/keep-email) (not token)) + (cj/keep--warn "no-token")) + (t + (let* ((out (generate-new-buffer " *keep-bridge-out*")) + (err (generate-new-buffer " *keep-bridge-err*")) + (process-environment + (append (list (concat "KEEP_EMAIL=" cj/keep-email) + (concat "KEEP_MASTER_TOKEN=" token)) + process-environment))) + (message "Keep: fetching...") + (make-process + :name "keep-bridge" + :buffer out + :stderr err + :command (list cj/keep-python cj/keep--bridge-script) + :sentinel + (lambda (proc _event) + (when (memq (process-status proc) '(exit signal)) + (unwind-protect + (if (and (eq (process-status proc) 'exit) + (= (process-exit-status proc) 0)) + (let ((n (cj/keep--write-notes + (with-current-buffer out (buffer-string))))) + (message "Keep: wrote %d notes to %s" n keep-file)) + (cj/keep--warn + (string-trim (if (buffer-live-p err) + (with-current-buffer err (buffer-string)) + "")))) + (when (buffer-live-p out) (kill-buffer out)) + (when (buffer-live-p err) (kill-buffer err))))))))))) + +;;;###autoload +(defun cj/keep-open () + "Open the generated Keep org file, offering to refresh when it's absent." + (interactive) + (if (file-exists-p keep-file) + (find-file keep-file) + (if (y-or-n-p "Keep file doesn't exist yet. Refresh now? ") + (cj/keep-refresh) + (message "Run M-x cj/keep-refresh to generate it")))) + +;; --------------------------------- Glue / keys ------------------------------- + +(defvar cj/keep-prefix-map + (let ((map (make-sparse-keymap))) + (define-key map "r" #'cj/keep-refresh) + (define-key map "o" #'cj/keep-open) + map) + "Prefix keymap for Google Keep commands (bound to \\=`C-c k').") + +(keymap-global-set "C-c k" cj/keep-prefix-map) + +;; Warn at load if the interpreter is missing; gkeepapi/token failures surface +;; at refresh time via the bridge's stderr reason token. +(cj/executable-find-or-warn cj/keep-python "Google Keep bridge" 'google-keep-config) + +(provide 'google-keep-config) +;;; google-keep-config.el ends here diff --git a/modules/prog-general.el b/modules/prog-general.el index 99b3cbfab..8e317413c 100644 --- a/modules/prog-general.el +++ b/modules/prog-general.el @@ -290,6 +290,16 @@ seeded by `cj/deadgrep--initial-term'. Shared tail of the deadgrep commands." (with-eval-after-load 'dired (keymap-set dired-mode-map "G" #'cj/deadgrep-here)) +;; ------------------------------------ wgrep ---------------------------------- +;; Make a grep buffer editable, then write the edits back across files -- turns +;; a consult-grep / embark-export result into a project-wide find-and-replace. +;; In a grep buffer: C-c C-p to start editing, C-c C-c to apply. + +(use-package wgrep + :custom + (wgrep-auto-save-buffer t) ;; save the touched files when applying + (wgrep-change-readonly-file t)) ;; let edits flow into read-only buffers + ;; ---------------------------------- Snippets --------------------------------- ;; reusable code and text diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el index c099e0834..cb0fc5697 100644 --- a/modules/ui-navigation.el +++ b/modules/ui-navigation.el @@ -283,5 +283,15 @@ With numeric prefix ARG, re-open the ARGth most-recently-killed file :config (winner-mode 1)) +;; ------------------------------- Cursor Jump (avy) --------------------------- +;; Jump anywhere visible by typing a few of the target's characters, then the +;; decision-tree key avy overlays. Fills the in-buffer motion gap that windmove +;; (windows) and isearch (text) leave. + +(use-package avy + :bind (("C-:" . avy-goto-char-timer) ;; type chars, pause, jump to a match + ("M-g w" . avy-goto-word-1) + ("M-g l" . avy-goto-line))) + (provide 'ui-navigation) ;;; ui-navigation.el ends here diff --git a/modules/user-constants.el b/modules/user-constants.el index b392212ed..570b142fb 100644 --- a/modules/user-constants.el +++ b/modules/user-constants.el @@ -167,6 +167,12 @@ Proton Calendar.") Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.") +(defvar keep-file (expand-file-name "data/keep.org" user-emacs-directory) + "The location of the generated org file containing Google Keep notes. +A read-only view regenerated by `cj/keep-refresh'; edits here do not +sync back to Keep. Stored in .emacs.d/data/ so each machine syncs +independently.") + (defvar reference-file (expand-file-name "reference.org" org-dir) "The location of the org file containing reference information.") 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'); diff --git a/tests/test-calendar-sync--apply-single-exception.el b/tests/test-calendar-sync--apply-single-exception.el index 3d2342708..f23104d98 100644 --- a/tests/test-calendar-sync--apply-single-exception.el +++ b/tests/test-calendar-sync--apply-single-exception.el @@ -105,5 +105,42 @@ (plist-get (calendar-sync--apply-single-exception occ exc) :url))))) +;;; Status re-derivation from overridden attendees (chime handoff 2026-06-24) + +(ert-deftest test-calendar-sync--apply-single-exception-declined-occurrence-rederives-status () + "Normal: a declined single occurrence re-derives :status from the override attendees." + (let ((calendar-sync-user-emails '("craig@example.com")) + (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc")) + (exc (list :start '(2026 6 24 16 0) + :attendees (list (list :email "craig@example.com" :partstat "DECLINED"))))) + (should (equal "declined" + (plist-get (calendar-sync--apply-single-exception occ exc) :status))))) + +(ert-deftest test-calendar-sync--apply-single-exception-no-attendee-override-keeps-status () + "Boundary: an exception with no attendee block leaves the inherited :status intact." + (let ((calendar-sync-user-emails '("craig@example.com")) + (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc")) + (exc (list :start '(2026 6 24 16 0) :summary "Moved"))) + (should (equal "accepted" + (plist-get (calendar-sync--apply-single-exception occ exc) :status))))) + +(ert-deftest test-calendar-sync--apply-single-exception-accepted-override-stays-accepted () + "Normal: an accepted attendee override keeps :status accepted." + (let ((calendar-sync-user-emails '("craig@example.com")) + (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc")) + (exc (list :start '(2026 6 24 16 0) + :attendees (list (list :email "craig@example.com" :partstat "ACCEPTED"))))) + (should (equal "accepted" + (plist-get (calendar-sync--apply-single-exception occ exc) :status))))) + +(ert-deftest test-calendar-sync--apply-single-exception-override-without-user-keeps-status () + "Boundary: override attendees that don't include the user leave :status intact." + (let ((calendar-sync-user-emails '("craig@example.com")) + (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc")) + (exc (list :start '(2026 6 24 16 0) + :attendees (list (list :email "someone@else.com" :partstat "DECLINED"))))) + (should (equal "accepted" + (plist-get (calendar-sync--apply-single-exception occ exc) :status))))) + (provide 'test-calendar-sync--apply-single-exception) ;;; test-calendar-sync--apply-single-exception.el ends here diff --git a/tests/test-google-keep-config.el b/tests/test-google-keep-config.el new file mode 100644 index 000000000..690355506 --- /dev/null +++ b/tests/test-google-keep-config.el @@ -0,0 +1,142 @@ +;;; test-google-keep-config.el --- Tests for google-keep-config -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the pure JSON-to-org core of google-keep-config.el (the part that +;; later extracts to a package) plus the parse-render-write chain. The bridge +;; subprocess + auth are the IO boundary, exercised live once the token is set. + +;;; Code: + +(require 'ert) +(require 'google-keep-config) + +(defun test-google-keep--note (&rest overrides) + "Build a note alist (parse-shaped) with OVERRIDES merged in." + (let ((base (list (cons 'id "abc") + (cons 'title "Groceries") + (cons 'text "milk\neggs") + (cons 'labels '("shopping" "home")) + (cons 'pinned nil) + (cons 'archived nil) + (cons 'color "WHITE") + (cons 'updated "2026-06-25T04:00:00Z")))) + (dolist (pair overrides base) + (setf (alist-get (car pair) base) (cdr pair))))) + +;;; cj/keep--parse-json + +(ert-deftest test-google-keep-parse-json-array () + "Normal: a JSON array parses to a list of note alists." + (let ((notes (cj/keep--parse-json + "[{\"id\":\"a\",\"title\":\"T\",\"labels\":[\"x\"],\"pinned\":true}]"))) + (should (= 1 (length notes))) + (should (equal "a" (alist-get 'id (car notes)))) + (should (equal '("x") (alist-get 'labels (car notes)))) + (should (eq t (alist-get 'pinned (car notes)))))) + +(ert-deftest test-google-keep-parse-json-empty () + "Boundary: an empty Keep ([]) parses to an empty list." + (should (null (cj/keep--parse-json "[]")))) + +;;; cj/keep--label-to-tag + +(ert-deftest test-google-keep-label-to-tag-plain () + "Normal: an alphanumeric label is unchanged." + (should (equal "shopping" (cj/keep--label-to-tag "shopping")))) + +(ert-deftest test-google-keep-label-to-tag-sanitizes () + "Boundary: spaces and punctuation become underscores (valid org tag chars)." + (should (equal "to_do_list_" (cj/keep--label-to-tag "to do/list!")))) + +;;; cj/keep--note-tags + +(ert-deftest test-google-keep-note-tags-labels () + "Normal: labels render as a trailing org-tag string." + (should (equal " :shopping:home:" (cj/keep--note-tags (test-google-keep--note))))) + +(ert-deftest test-google-keep-note-tags-archived () + "Normal: an archived note gains the archived tag." + (should (equal " :shopping:home:archived:" + (cj/keep--note-tags (test-google-keep--note (cons 'archived t)))))) + +(ert-deftest test-google-keep-note-tags-none () + "Boundary: no labels and not archived yields an empty tag string." + (should (equal "" (cj/keep--note-tags + (test-google-keep--note (cons 'labels nil)))))) + +;;; cj/keep--note-heading + +(ert-deftest test-google-keep-note-heading-full () + "Normal: a full note renders heading, properties, link, and body." + (let ((s (cj/keep--note-heading (test-google-keep--note)))) + (should (string-match-p "\\`\\* Groceries :shopping:home:\n" s)) + (should (string-match-p ":KEEP_ID: abc\n" s)) + (should (string-match-p ":UPDATED: 2026-06-25T04:00:00Z\n" s)) + (should (string-match-p "\\[\\[https://keep.google.com/#NOTE/abc\\]\\[open in Keep\\]\\]" s)) + (should (string-match-p "milk\neggs\n" s)))) + +(ert-deftest test-google-keep-note-heading-untitled () + "Boundary: an empty title falls back to (untitled)." + (let ((s (cj/keep--note-heading (test-google-keep--note (cons 'title ""))))) + (should (string-match-p "\\`\\* (untitled)" s)))) + +(ert-deftest test-google-keep-note-heading-empty-text () + "Boundary: an empty body emits no trailing text block." + (let ((s (cj/keep--note-heading + (test-google-keep--note (cons 'text "") (cons 'labels nil))))) + (should-not (string-match-p "open in Keep\\]\\]\n.+[^\n]" s)))) + +;;; cj/keep--sort-pinned-first + +(ert-deftest test-google-keep-sort-pinned-first () + "Normal: pinned notes come first, order otherwise preserved." + (let* ((a (test-google-keep--note (cons 'id "a") (cons 'pinned nil))) + (b (test-google-keep--note (cons 'id "b") (cons 'pinned t))) + (c (test-google-keep--note (cons 'id "c") (cons 'pinned nil))) + (sorted (cj/keep--sort-pinned-first (list a b c)))) + (should (equal '("b" "a" "c") (mapcar (lambda (n) (alist-get 'id n)) sorted))))) + +;;; cj/keep--render + +(ert-deftest test-google-keep-render-header-and-notes () + "Normal: the page carries the read-only header and a heading per note." + (let ((s (cj/keep--render (list (test-google-keep--note)) "2026-06-25 04:00"))) + (should (string-match-p "read-only view" s)) + (should (string-match-p "Last refresh: 2026-06-25 04:00" s)) + (should (string-match-p "^\\* Groceries" s)))) + +(ert-deftest test-google-keep-render-empty () + "Boundary: no notes still produces a valid header-only page." + (let ((s (cj/keep--render nil))) + (should (string-match-p "#\\+TITLE: Google Keep" s)) + (should-not (string-match-p "^\\* " s)))) + +;;; cj/keep--write-atomically + the parse-render-write chain + +(ert-deftest test-google-keep-write-atomically () + "Normal: content lands in the target file via temp + rename." + (let* ((dir (make-temp-file "keep-test-" t)) + (file (expand-file-name "keep.org" dir))) + (unwind-protect + (progn + (cj/keep--write-atomically "hello\n" file) + (should (equal "hello\n" + (with-temp-buffer (insert-file-contents file) + (buffer-string))))) + (delete-directory dir t)))) + +(ert-deftest test-google-keep-write-notes-chain () + "Normal: JSON in, a rendered org file out, with the note count returned." + (let* ((dir (make-temp-file "keep-test-" t)) + (keep-file (expand-file-name "keep.org" dir))) + (unwind-protect + (let ((n (cj/keep--write-notes + "[{\"id\":\"a\",\"title\":\"One\",\"labels\":[],\"pinned\":false,\"archived\":false,\"color\":\"WHITE\",\"updated\":\"2026-06-25T04:00:00Z\"}]"))) + (should (= 1 n)) + (should (string-match-p "^\\* One" + (with-temp-buffer (insert-file-contents keep-file) + (buffer-string))))) + (delete-directory dir t)))) + +(provide 'test-google-keep-config) +;;; test-google-keep-config.el ends here diff --git a/tests/test-prog-lsp.el b/tests/test-prog-lsp.el new file mode 100644 index 000000000..7e38111d0 --- /dev/null +++ b/tests/test-prog-lsp.el @@ -0,0 +1,66 @@ +;;; test-prog-lsp.el --- Startup smoke test for LSP config resolution -*- lexical-binding: t; -*- + +;;; Commentary: +;; A narrow smoke test of prog-lsp.el, the central LSP module. It pins the +;; invariants that should hold the moment the config loads, before any server +;; starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start a slow +;; LSP), the file-watch-ignore defaults live in one idempotent place, the eldoc +;; provider is stripped from the global hook, and a mode never accrues a +;; duplicate lsp-deferred entry. The generic :config defaults are deferred to +;; lsp-mode's own load (see the make-test no-package-initialize note in +;; CLAUDE.md), so this tests the top-level :init and helper surface, which runs. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'use-package) +(require 'prog-lsp) + +;; lsp-mode's defcustom isn't loaded under make test, and prog-lsp's bare +;; `(defvar lsp-file-watch-ignored-directories)' only marks it special within +;; that file's unit. Declare it special here too so the `let' bindings below +;; bind dynamically (the helper reads it through the symbol via add-to-list). +(defvar lsp-file-watch-ignored-directories nil) + +(ert-deftest test-prog-lsp-enable-remote-nil () + "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files." + (should (boundp 'lsp-enable-remote)) + (should (null lsp-enable-remote))) + +(ert-deftest test-prog-lsp-file-watch-adds-extras () + "Normal: the build/cache ignore patterns get appended to lsp's watch-ignore list." + (let ((lsp-file-watch-ignored-directories '("[/\\\\]\\.git\\'"))) + (cj/lsp--add-file-watch-ignored-extras) + (dolist (pattern cj/lsp-file-watch-ignored-extras) + (should (member pattern lsp-file-watch-ignored-directories))) + (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories)))) + +(ert-deftest test-prog-lsp-file-watch-idempotent () + "Boundary: adding the extras twice leaves each pattern present exactly once." + (let ((lsp-file-watch-ignored-directories '())) + (cj/lsp--add-file-watch-ignored-extras) + (cj/lsp--add-file-watch-ignored-extras) + (dolist (pattern cj/lsp-file-watch-ignored-extras) + (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories + :test #'equal)))))) + +(ert-deftest test-prog-lsp-eldoc-provider-removed-globally () + "Normal: the global eldoc provider is stripped so lsp can't reattach it." + (let ((eldoc-documentation-functions + (list #'lsp-eldoc-function #'ignore))) + (cj/lsp--remove-eldoc-provider-global) + (should-not (memq 'lsp-eldoc-function eldoc-documentation-functions)) + (should (memq 'ignore eldoc-documentation-functions)))) + +(ert-deftest test-prog-lsp-no-duplicate-mode-hook () + "Boundary: a mode prog-lsp wires never holds more than one lsp-deferred entry. +prog-lsp and the per-language modules both add lsp-deferred for some modes; +add-hook dedups identical symbols, and this pins that invariant so a future +non-symbol (lambda) addition that breaks it gets caught." + (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook)) + (when (boundp hook) + (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook))))))) + +(provide 'test-prog-lsp) +;;; test-prog-lsp.el ends here @@ -55,14 +55,40 @@ Tags are additive. For example, a small wrong-behavior fix can be =:bug:quick:=, and a feature that requires internal restructuring can be =:feature:refactor:=. * Emacs Open Work +** TODO [#B] eww User-Agent advice may not inject under Emacs 30 :bug: +The UA-injection advice =my-eww--inject-user-agent= (=modules/eww-config.el:47=) gates on =(derived-mode-p 'eww-mode)= and is an =:around= on =url-retrieve= / =url-retrieve-synchronously=. Its tests (=tests/test-eww-config-user-agent-advice.el=) fail under =make test=: =test-eww-ua-injected-in-eww-buffer= (:21) and =test-eww-ua-replaces-existing-and-keeps-other-headers= (:42) both see no injected UA, because =(derived-mode-p 'eww-mode)= returns nil in the test's temp buffer where the mode is set with a bare =(setq major-mode 'eww-mode)=. Two possibilities: (a) test-only artifact — a bare setq doesn't establish the mode the way =derived-mode-p= now resolves it, so the test should set the mode properly (call =eww-mode=, or set =derived-mode-parent=); or (b) a real Emacs-30 =derived-mode-p= change that means the advice no longer fires in real eww buffers either, so the desktop User-Agent isn't actually sent. Check (b) first: in a live eww page, =M-: (derived-mode-p 'eww-mode)= — if nil, the gate is broken in production (fix to =(eq major-mode 'eww-mode)= or =provided-mode-derived-p=). If only the test is wrong, fix its mode setup. Pre-existing (eww-config + test unchanged this session); surfaced 2026-06-25 running the full suite during the google-keep work. The third test (=test-eww-ua-not-injected-outside-eww=) passes. ** TODO [#B] first f12 doesn't toggle the term window :bug:solo: The first =f12= of a session flashes the terminal open and immediately closes it, as if the toggle fired on then off; a second =f12= then works. Seen across two separate sessions. From the roam inbox 2026-06-24. +** TODO [#B] F12 pops EAT instead of ghostel :feature:studio: +Switch the F12 terminal toggle from ghostel/ghostty to EAT (emulator-for-terminals, pure elisp). The draw: EAT renders entirely in elisp, so its whole palette is real Emacs faces (=eat-term-color-0= .. =eat-term-color-15=, foreground/background, cursor), which makes it fully themeable from the theme — and a fun theme-studio coverage target. Steps: install =eat=; wire F12 to pop/toggle an EAT terminal (mind the =ghostel-keymap-exceptions= + rebuild gotcha if any ghostel F-key wiring lingers; the new path is plain Emacs keymaps); theme the =eat-term-color-*= faces (candidate to surface in theme-studio). Tradeoff to accept knowingly (themeability research 2026-06-24): ghostel is actually the most live-themeable — it has an =enable-theme-functions= resync hook and a dedicated default fg/bg face, whereas EAT needs a buffer reload to pick up a theme change and exposes no default fg/bg defcustom. So this trades ghostel's automatic theme-resync for EAT's pure-elisp face control. Spawned from the terminal-themeability comparison. ** TODO [#C] org-capture popup leaks f12 / f10 / f11 / ai-term keys :bug: While the org-capture popup is open, the global F-keys (the =f12= term, =f10= / =f11=, the ai-term family) still fire and pop a terminal over the capture. Disable those keys for the duration of the capture popup if there's a clean way. Research first and report; if it's too invasive, defer or cancel rather than force it. From the roam inbox 2026-06-24. -** TODO [#C] dirvish image previews missing in the pictures dir :bug: -Dirvish (the =super + f= file manager) shows no image preview when browsing =~/Pictures=, so picking a wallpaper is blind. The preview pane is empty for image files where a thumbnail should render. Want image rendering in the dirvish preview pane for image directories. From the roam inbox 2026-06-24. +** CANCELLED [#C] dirvish image previews missing in the pictures dir :bug: +CLOSED: [2026-06-25 Thu] +Craig couldn't reproduce — image previews render fine in dirvish now. Cancelled. +** DONE [#B] calendar-sync: a declined single occurrence keeps :STATUS: accepted :bug:solo: +CLOSED: [2026-06-25 Thu] +A recurring event declined for just one occurrence synced out with =:STATUS: accepted= (chime then faithfully showed it). Root cause (diagnosed by a chime session, 2026-06-24): =calendar-sync--apply-single-exception= merged the override's =:attendees= but never re-derived =:status=, so the occurrence kept the series master's accepted status, and =calendar-sync--filter-declined= (which keys off =:status=) didn't drop it. Fix (TDD): in =apply-single-exception=, when overriding =:attendees=, re-derive =:status= via =calendar-sync--find-user-status= against =calendar-sync-user-emails=. Four new tests in =test-calendar-sync--apply-single-exception.el= (declined → "declined"; no-attendee override → inherited intact; accepted override → accepted; override without the user → inherited intact); recurrence + find-user-status + integration suites unchanged. Live-reloaded; a manual re-sync ran clean. The specific 2026-06-24 Arusyak occurrence is past now (its RECURRENCE-ID override aged out of the feed), so the live confirmation lands on the next single-occurrence decline. ** PROJECT [#A] Manual testing and validation Exercised once the phases above land. +*** VERIFY Google Keep v1 live setup and first fetch +What we're verifying: read-only v1 fetches real Keep notes and renders =data/keep.org= once the one-time gkeepapi + master-token setup is done. The code and 27 tests (12 Python + 15 ERT) are green; this is the live-credential step only Craig can run. +- Install the client into the interpreter =cj/keep-python= uses: =pip install gkeepapi= (or pipx). +- Obtain a Google master token (one-time, via gkeepapi's current login/gpsoauth flow against your account). +- Set your email: +#+begin_src emacs-lisp +(setq cj/keep-email "you@gmail.com") +#+end_src +- Add the token to =~/.authinfo.gpg= (line: =machine google-keep login you@gmail.com password <master-token>=), then clear the auth cache so the daemon sees it: +#+begin_src emacs-lisp +(auth-source-forget-all-cached) +#+end_src +- Fetch (or press =C-c k r=): +#+begin_src emacs-lisp +(cj/keep-refresh) +#+end_src +- Open the result with =C-c k o=. +Expected: =data/keep.org= lists your Keep notes, pinned first, each a header with title/body, labels as org tags, a property drawer (=:KEEP_ID:= / =:UPDATED:= / ...), and an "open in Keep" link. A missing piece (gkeepapi / token / auth) shows a clear =*Warnings*= message naming it, not a crash. *** TODO theme-studio preview-locate discoverability read What we're verifying: the locate hover/flash actually feels discoverable in a live frame — the subjective read the deterministic gates can't make. - Open theme-studio in Chrome (=make theme-studio-open=, or open theme-studio.html). @@ -1890,13 +1916,8 @@ Pitfalls: - Do not accidentally re-enable UI/doc/sideline behavior that was explicitly disabled for performance. -***** TODO [#B] Add a startup smoke test for LSP config resolution :quick:solo: - -Keep this narrow. A useful test can require the LSP-related modules with mocked -=use-package= side effects and assert that: -- generic defaults are set in one place, -- no duplicate hook entries are installed for the same mode, -- =lsp-enable-remote= remains nil. +***** 2026-06-25 Thu @ 01:53:48 -0400 Added the LSP config-resolution smoke test +=tests/test-prog-lsp.el= (5 ERT tests) pins prog-lsp's load-time invariants: =lsp-enable-remote= stays nil (no auto-start on TRAMP files), the file-watch-ignore defaults live in one idempotent helper (=cj/lsp--add-file-watch-ignored-extras=), the eldoc provider is stripped from the global hook, and no mode holds a duplicate =lsp-deferred= entry. Tests the top-level =:init= + helper surface rather than the =:config= defaults, which defer to lsp-mode's own load under =make test= (the no-package-initialize constraint). Hit and fixed the lexical-binding special-var trap on =lsp-file-watch-ignored-directories= in the test. **** TODO [#B] Gate tree-sitter grammar auto-install behind an explicit policy @@ -2922,11 +2943,12 @@ The step-to-next-agent family (s-F9 and friends) should cycle to a running ai-te :END: Allow creating an ai-term backed by any of Claude, Codex, or a local LLM via ollama, with the backend chosen seamlessly at the start of the session. ai-term currently assumes Claude; generalize the launch path so the agent backend is a selectable parameter and switching between them at session start is frictionless. Routed here from the rulesets roam-inbox item "multiple agent source improvements" (its bullet 3 asked to send emacs this note); the item's other bullets — naming the agent so non-Claude agents aren't called "Claude", and tightening workflow wording for Codex's more literal reading — stay with rulesets. -** TODO [#C] Compare terminal themeability: EAT vs vterm vs ghostel :feature:solo:next: +** DONE [#C] Compare terminal themeability: EAT vs vterm vs ghostel :feature:solo:next: +CLOSED: [2026-06-25 Thu] :PROPERTIES: :LAST_REVIEWED: 2026-06-22 :END: -Research how completely each of EAT, vterm, and ghostel can be themed — in particular how far theme studio can theme each terminal and what it leaves out. Produce a comparison document, then review it with an eye to whether ai-term should move off ghostel (current) to EAT or vterm. Connects to the chime/emacs-wttrin/pearl face-exposure theme-studio thread. From the roam inbox. +Researched 2026-06-24. All three expose the 16 ANSI colors as Emacs faces (=eat-term-color-*=, =vterm-color-*=, =ghostel-color-*=, each inheriting =ansi-color-*= / =term-color-*=). Ghostel is the most live-themeable: it alone registers an =enable-theme-functions= resync hook (repaints live buffers on a theme change) and exposes a dedicated =ghostel-default= face for the terminal's default fg/bg. EAT (pure elisp, where the faces are the real render source) and vterm (native, faces read at render) both expose themeable palettes but need a buffer reload to pick up a theme switch and give less default-fg/bg control. Outcome: Craig is moving F12 to EAT anyway, for pure-elisp face control and the fun of theming it — see "F12 pops EAT instead of ghostel" above, which carries the resync tradeoff knowingly. ** VERIFY [#C] Remove unused system-power keybindings :refactor:quick:next: :PROPERTIES: @@ -3756,9 +3778,10 @@ These may override useful defaults - review and pick better bindings: :END: Display slack.el message and thread buffers in a dedicated popup window (side or bottom) and reuse that one window instead of spawning a new window per buffer. Likely a =display-buffer-alist= rule (or popper integration) in =modules/slack-config.el=. -** TODO [#C] google-keep in-editor integration — build, module-to-package :feature: -Build a native Keep integration: an org page of notes (each note an org header), read-only in v1, a gkeepapi Python subprocess bridge for data (the MCP is agent-only, not callable from elisp), auth via authinfo.gpg, eventually extracted to a standalone package. vNext: read-write, the org-capture-style popup, list rendering. -Spec: [[file:docs/specs/google-keep-emacs-integration-spec.org][google-keep-emacs-integration-spec.org]]. Five decisions are drafted with recommended calls and await Craig's confirmation (shape, direction, data path, auth, structure); the spec stays draft until they resolve. +** DOING [#C] google-keep in-editor integration — build, module-to-package :feature: +v1 (read-only) implemented and tested (Phases 1-3): the gkeepapi Python bridge (=scripts/google-keep/keep-bridge.py=, 12 tests), the elisp core + =cj/keep-refresh= renderer with atomic writes and async make-process (=modules/google-keep-config.el=, 15 ERT tests), un-orphaned under a =C-c k= prefix, graceful warning when gkeepapi/token/auth is missing. The pure JSON-to-org core is kept extractable per the spec. Live fetch needs the one-time gkeepapi + master-token setup — see "Google Keep v1 live setup and first fetch" under Manual testing and validation. +Next: v2 (read-write — create/edit back to Keep, with a staleness guard) per the spec, the immediate follow-on once the live read is confirmed. Later: list/checkbox rendering, package extraction. +Spec: [[file:docs/specs/google-keep-emacs-integration-spec.org][google-keep-emacs-integration-spec.org]] (Ready, 2 review rounds; all five decisions resolved 2026-06-25). ** TODO [#D] Theme Studio nerd-icons vNext follow-ups :feature: Deferred from [[file:docs/specs/theme-studio-nerd-icons-colors-spec.org][theme-studio-nerd-icons-colors-spec.org]]: extend the legend to buffer-mode and command/symbol categories if the file set proves insufficient; |
