aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-25 00:58:22 -0400
committerCraig Jennings <c@cjennings.net>2026-06-25 00:58:22 -0400
commit85452c2a841585884d937aad9622a4c10b689686 (patch)
treec13950ebe3369684652802bd8cfe8099cfa97dee
parentaac7bff8335d43408ef62293f0beb8c8f7a8165d (diff)
downloaddotemacs-85452c2a841585884d937aad9622a4c10b689686.tar.gz
dotemacs-85452c2a841585884d937aad9622a4c10b689686.zip
feat(google-keep): org-page renderer, refresh command, keybindings (Phase 2-3)
The elisp side of the Keep integration: a pure JSON-to-org core (parse, tag/heading/render helpers) kept free of .emacs.d specifics for later extraction, plus the IO runner cj/keep-refresh (async make-process + sentinel, atomic temp-then-rename write to keep-file, stderr-token to display-warning) and the glue (C-c k prefix, executable warning, require in init.el). 15 ERT tests over the core and the parse-render-write chain. Read-only v1; live fetch needs the one-time gkeepapi + token setup.
-rw-r--r--init.el1
-rw-r--r--modules/google-keep-config.el210
-rw-r--r--modules/user-constants.el6
-rw-r--r--tests/test-google-keep-config.el142
4 files changed, 359 insertions, 0 deletions
diff --git a/init.el b/init.el
index 589e46591..2fa34ab4c 100644
--- a/init.el
+++ b/init.el
@@ -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/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/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/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