;;; google-keep-config.el --- Google Keep -> org integration -*- lexical-binding: t; coding: utf-8; -*- ;; author Craig Jennings ;;; 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 password ;; 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