1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
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
|